Files
scrum-poker-internal/resources/js/components/rooms/RoomSeat.vue
T
2026-09-01 11:59:00 +02:00

602 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { CircleUserRound, Crown } from '@lucide/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import type { CSSProperties } from 'vue';
import type { RoomSeatProps } from '@/types/room';
const props = defineProps<RoomSeatProps>();
const emit = defineEmits<{
throwPaperBall: [playerId: number, chargePercentage: number];
}>();
const MAX_CHARGE_PERCENTAGE = 300;
const CHARGE_DURATION = 3_000;
const chargePercentage = ref(0);
const chargeStartedAt = ref<number | null>(null);
let chargeAnimationFrame: number | null = null;
let ignoreClickTimer: number | null = null;
let ignoreNextClick = false;
const isCharging = computed(() => chargeStartedAt.value !== null);
const seatStyle = computed<CSSProperties>(() => ({
...props.player.seatStyle,
'--paper-ball-knockback-x': `${props.paperBallKnockback.x}px`,
'--paper-ball-knockback-y': `${props.paperBallKnockback.y}px`,
}));
function updateCharge(timestamp: number): void {
if (chargeStartedAt.value === null) {
return;
}
chargePercentage.value = Math.min(
MAX_CHARGE_PERCENTAGE,
((timestamp - chargeStartedAt.value) / CHARGE_DURATION) *
MAX_CHARGE_PERCENTAGE,
);
if (chargePercentage.value < MAX_CHARGE_PERCENTAGE) {
chargeAnimationFrame = window.requestAnimationFrame(updateCharge);
} else {
chargeAnimationFrame = null;
}
}
function startCharging(): void {
if (props.throwDisabled || chargeStartedAt.value !== null) {
return;
}
chargePercentage.value = 0;
chargeStartedAt.value = performance.now();
chargeAnimationFrame = window.requestAnimationFrame(updateCharge);
}
function stopChargeAnimation(): void {
if (chargeAnimationFrame !== null) {
window.cancelAnimationFrame(chargeAnimationFrame);
chargeAnimationFrame = null;
}
}
function resetCharge(): void {
stopChargeAnimation();
chargeStartedAt.value = null;
chargePercentage.value = 0;
}
function finishCharging(): void {
if (chargeStartedAt.value === null) {
return;
}
updateCharge(performance.now());
const releasedChargePercentage = Math.min(
MAX_CHARGE_PERCENTAGE,
Math.floor(chargePercentage.value),
);
resetCharge();
emit('throwPaperBall', props.player.id, releasedChargePercentage);
ignoreNextClick = true;
if (ignoreClickTimer !== null) {
window.clearTimeout(ignoreClickTimer);
}
ignoreClickTimer = window.setTimeout(() => {
ignoreNextClick = false;
ignoreClickTimer = null;
});
}
function startPointerCharge(event: PointerEvent): void {
if (event.button !== 0) {
return;
}
(event.currentTarget as HTMLButtonElement).setPointerCapture(
event.pointerId,
);
startCharging();
}
function finishPointerCharge(event: PointerEvent): void {
const button = event.currentTarget as HTMLButtonElement;
if (button.hasPointerCapture(event.pointerId)) {
button.releasePointerCapture(event.pointerId);
}
finishCharging();
}
function cancelPointerCharge(): void {
resetCharge();
}
function handleKeyDown(event: KeyboardEvent): void {
if (!['Enter', ' '].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (!event.repeat) {
startCharging();
}
}
function handleKeyUp(event: KeyboardEvent): void {
if (!['Enter', ' '].includes(event.key)) {
return;
}
event.preventDefault();
event.stopPropagation();
finishCharging();
}
function handleClick(): void {
if (ignoreNextClick) {
ignoreNextClick = false;
return;
}
emit('throwPaperBall', props.player.id, 0);
}
function chargeSegmentProgress(segment: number): number {
return Math.min(100, Math.max(0, chargePercentage.value - segment * 100));
}
function chargeColor(): string {
if (chargePercentage.value > 200) {
return '#ef4444';
}
if (chargePercentage.value > 100) {
return '#f97316';
}
return '#22c55e';
}
onBeforeUnmount(() => {
stopChargeAnimation();
if (ignoreClickTimer !== null) {
window.clearTimeout(ignoreClickTimer);
}
});
function cardLabel(value: string | null): string {
if (value === null) {
return '–';
}
return props.cards.find((card) => card.key === value)?.label ?? value;
}
function isCurrentPlayer(): boolean {
return props.player.id === props.currentPlayerId;
}
function playerVote(): string | null {
return isCurrentPlayer() ? props.currentVote : props.player.vote;
}
</script>
<template>
<article
class="player-seat"
:class="{ 'is-paper-ball-hit': isPaperBallHit }"
:style="seatStyle"
:data-player-id="player.id"
>
<div
v-if="player.has_voted"
class="seat-card"
:aria-label="`${player.name} hat eine Karte gewählt`"
>
<div
v-if="isCurrentPlayer() && !presenterMode"
class="own-vote-card"
:class="{ 'is-revealed': revealed }"
>
{{ cardLabel(playerVote()) }}
</div>
<div v-else class="flip-card" :class="{ 'is-revealed': revealed }">
<div class="flip-card-inner">
<div class="flip-card-face flip-card-back">
<span>SP</span>
</div>
<div class="flip-card-face flip-card-front">
{{ cardLabel(playerVote()) }}
</div>
</div>
</div>
</div>
<div
class="mx-auto grid size-12 place-items-center rounded-full border border-slate-600 bg-slate-800 text-slate-300 sm:size-14"
:data-player-avatar="player.id"
>
<CircleUserRound
class="size-7 sm:size-8"
:stroke-width="1.7"
aria-hidden="true"
/>
<span class="sr-only">Profilbild von {{ player.name }}</span>
</div>
<div
class="player-details mt-2 flex flex-col items-center gap-1"
:class="{ 'player-details-above': player.isTopSeat }"
>
<p
class="player-name max-w-32 text-center text-sm leading-tight font-semibold text-slate-100"
>
{{ player.name }}
</p>
<div
v-if="player.is_moderator || isCurrentPlayer()"
class="flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[11px]"
>
<span
v-if="player.is_moderator"
class="inline-flex items-center gap-1 text-amber-300"
>
<Crown class="size-3 fill-amber-300" aria-hidden="true" />
Moderator
</span>
<span v-if="isCurrentPlayer()" class="text-cyan-300">Du</span>
</div>
</div>
<button
v-if="!isCurrentPlayer()"
type="button"
class="throw-paper-ball"
:class="{ 'is-charging': isCharging }"
:disabled="throwDisabled"
:aria-label="`Papierknäuel auf ${player.name} werfen – gedrückt halten zum Aufladen${isCharging ? `, ${Math.floor(chargePercentage)} Prozent geladen` : ''}`"
:aria-pressed="isCharging"
:title="`Papierknäuel auf ${player.name} werfen – gedrückt halten zum Aufladen`"
@pointerdown.stop.prevent="startPointerCharge"
@pointerup.stop.prevent="finishPointerCharge"
@pointercancel="cancelPointerCharge"
@keydown="handleKeyDown"
@keyup="handleKeyUp"
@blur="cancelPointerCharge"
@click.stop.prevent="handleClick"
>
<svg
v-if="isCharging"
class="charge-rings"
viewBox="0 0 48 48"
aria-hidden="true"
>
<g
v-for="segment in 3"
v-show="chargePercentage > (segment - 1) * 100"
:key="segment"
>
<circle
class="charge-ring-track"
cx="24"
cy="24"
:r="8 + segment * 4"
/>
<circle
class="charge-ring"
cx="24"
cy="24"
:r="8 + segment * 4"
pathLength="100"
:stroke="chargeColor()"
:stroke-dashoffset="
100 - chargeSegmentProgress(segment - 1)
"
/>
</g>
</svg>
<span class="throw-paper-ball-surface" aria-hidden="true">
<span class="paper-ball-mini"></span>
</span>
</button>
</article>
</template>
<style scoped>
.player-seat {
position: absolute;
z-index: 2;
width: 8rem;
transform: translate(-50%, -50%)
translate(
var(--paper-ball-knockback-x, 0),
var(--paper-ball-knockback-y, 0)
);
text-align: center;
}
.player-name {
overflow-wrap: anywhere;
}
.player-details-above {
transform: translateY(calc(-100% - 4.5rem));
}
.throw-paper-ball {
position: absolute;
top: 0.15rem;
right: -0.15rem;
z-index: 8;
display: grid;
width: 3rem;
height: 3rem;
place-items: center;
border: 0;
background: transparent;
opacity: 0;
transform: translateY(0.25rem) rotate(-8deg);
pointer-events: none;
touch-action: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.player-seat:hover .throw-paper-ball,
.player-seat:focus-within .throw-paper-ball {
opacity: 1;
transform: translateY(0) rotate(-8deg);
pointer-events: auto;
}
.throw-paper-ball:hover:not(:disabled) .throw-paper-ball-surface,
.throw-paper-ball:focus-visible .throw-paper-ball-surface,
.throw-paper-ball.is-charging .throw-paper-ball-surface {
background: #475569;
}
.throw-paper-ball:hover:not(:disabled),
.throw-paper-ball:focus-visible {
transform: translateY(-0.1rem) rotate(4deg);
}
.throw-paper-ball:focus-visible {
outline: 2px solid #67e8f9;
outline-offset: 2px;
}
.throw-paper-ball:disabled {
cursor: not-allowed;
opacity: 0;
pointer-events: none;
}
.throw-paper-ball-surface {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #334155;
transition: background-color 140ms ease;
}
.charge-rings {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
filter: drop-shadow(0 0 0.2rem rgb(15 23 42 / 75%));
}
.charge-ring-track,
.charge-ring {
fill: none;
stroke-width: 2.25;
}
.charge-ring-track {
stroke: rgb(148 163 184 / 32%);
}
.charge-ring {
transform: rotate(-90deg);
transform-origin: 24px 24px;
stroke-linecap: round;
stroke-dasharray: 100;
transition: stroke 100ms linear;
}
.paper-ball-mini {
display: block;
width: 0.95rem;
height: 0.95rem;
border: 1px solid rgb(100 116 139 / 65%);
border-radius: 48% 42% 54% 39%;
background:
linear-gradient(
35deg,
transparent 44%,
rgb(100 116 139 / 35%) 46% 51%,
transparent 53%
),
linear-gradient(
120deg,
transparent 35%,
rgb(148 163 184 / 45%) 37% 42%,
transparent 44%
),
#e2e8f0;
transform: rotate(18deg);
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: paper-ball-hit 380ms ease-out;
}
@keyframes paper-ball-hit {
0%,
100% {
transform: translateX(0) rotate(0deg);
}
28% {
transform: translateX(-0.35rem) rotate(-7deg);
}
62% {
transform: translateX(0.25rem) rotate(5deg);
}
}
.seat-card {
position: absolute;
top: 1.75rem;
left: 50%;
width: 3.15rem;
height: 4.35rem;
transform: translate(-50%, -50%) translate(var(--card-x), var(--card-y));
perspective: 800px;
}
.own-vote-card,
.flip-card-face {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 0.55rem;
font-size: 1.15rem;
font-weight: 800;
}
.own-vote-card {
border: 2px solid #cbd5e1;
background: #f8fafc;
color: #0f172a;
}
.own-vote-card.is-revealed {
border-color: rgb(252 211 77 / 90%);
background: #fef3c7;
}
.flip-card,
.flip-card-inner {
width: 100%;
height: 100%;
}
.flip-card-inner {
position: relative;
transform-style: preserve-3d;
transition: transform 520ms cubic-bezier(0.2, 0.75, 0.25, 1);
}
.flip-card.is-revealed .flip-card-inner {
transform: rotateY(180deg);
}
.flip-card-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.flip-card-back {
border: 2px solid rgb(103 232 249 / 70%);
background:
linear-gradient(
135deg,
transparent 42%,
rgb(103 232 249 / 20%) 42% 48%,
transparent 48% 92%,
rgb(103 232 249 / 20%) 92%
),
#17324a;
background-size: 1rem 1rem;
color: rgb(207 250 254 / 78%);
font-size: 0.7rem;
letter-spacing: 0.1em;
}
.flip-card-front {
transform: rotateY(180deg);
border: 2px solid rgb(252 211 77 / 90%);
background: #fffbeb;
color: #0f172a;
}
@media (max-width: 640px) {
.player-seat {
width: 6.5rem;
}
.player-details-above {
transform: translateY(calc(-100% - 4rem));
}
.seat-card {
top: 1.5rem;
width: 2.65rem;
height: 3.65rem;
transform: translate(-50%, -50%) translate(var(--card-x), var(--card-y))
scale(0.86);
}
.own-vote-card,
.flip-card-face {
font-size: 0.95rem;
}
}
@media (min-width: 768px) and (max-height: 800px) {
.player-seat {
width: 7rem;
}
.player-details {
margin-top: 0.25rem;
}
.seat-card {
top: 1.5rem;
width: 2.75rem;
height: 3.8rem;
}
.own-vote-card,
.flip-card-face {
font-size: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.flip-card-inner {
transition: none;
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: none;
}
}
</style>