Files
scrum-poker-internal/resources/js/pages/rooms/Show.vue
T

931 lines
27 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 { Head, Link, useForm, usePage, usePoll } from '@inertiajs/vue3';
import {
Check,
CircleUserRound,
Copy,
Crown,
Eye,
RotateCcw,
Trash2,
X,
} from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { usePaperBallToss } from '@/composables/usePaperBallToss';
import { useRoomRealtime } from '@/composables/useRoomRealtime';
import { login, register } from '@/routes';
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
import { store as storeVote } from '@/routes/room/vote';
import type { PokerCard, RoomPlayer, SeatStyle } from '@/types/room';
const props = defineProps<{
room: {
id: string;
title: string | null;
join_code: string;
current_round: number;
revealed: boolean;
state_version: number;
statistics: {
average: string | null;
median: string | null;
} | null;
deck: {
name: string;
cards: PokerCard[];
};
players: RoomPlayer[];
};
currentPlayer: {
id: number;
name: string;
is_moderator: boolean;
current_vote: string | null;
};
}>();
const page = usePage();
const roomNotice = computed(
() =>
page.props.roomNotice as {
guest_retention_days: number;
account_retention_days: number;
} | null,
);
const voteForm = useForm({
value: props.currentPlayer.current_vote ?? '',
});
const revealForm = useForm<{ room?: string }>({});
const nextRoundForm = useForm<{ room?: string }>({});
const resetForm = useForm<{ room?: string }>({});
const copied = ref(false);
const roomNoticeDismissed = ref(false);
const gameBoard = ref<HTMLElement | null>(null);
const { activePaperBalls, hitPlayerIds, throwingAtPlayerId, throwPaperBall } =
usePaperBallToss({
roomId: () => props.room.id,
currentPlayerId: () => props.currentPlayer.id,
board: gameBoard,
});
useRoomRealtime(
() => props.room.id,
() => props.room.state_version,
);
usePoll(30_000, {
only: ['room', 'currentPlayer'],
});
const positionedPlayers = computed(() => {
const currentPlayerIndex = props.room.players.findIndex(
(player) => player.id === props.currentPlayer.id,
);
const orderedPlayers =
currentPlayerIndex === -1
? props.room.players
: [
...props.room.players.slice(currentPlayerIndex),
...props.room.players.slice(0, currentPlayerIndex),
];
const playerCount = Math.max(orderedPlayers.length, 1);
return orderedPlayers.map((player, index) => {
const angle = Math.PI / 2 + (index * Math.PI * 2) / playerCount;
const horizontalRadius = 39;
const verticalRadius = 40;
const cardDistance = 96;
return {
...player,
isTopSeat: Math.sin(angle) < -0.25,
seatStyle: {
left: `${50 + Math.cos(angle) * horizontalRadius}%`,
top: `${48 + Math.sin(angle) * verticalRadius}%`,
'--card-x': `${-Math.cos(angle) * cardDistance}px`,
'--card-y': `${-Math.sin(angle) * cardDistance}px`,
} as SeatStyle,
};
});
});
watch(
() => props.currentPlayer.current_vote,
(value) => {
if (!voteForm.processing) {
voteForm.value = value ?? '';
}
},
);
function submitVote(value: string): void {
if (props.room.revealed || voteForm.processing) {
return;
}
voteForm.value = value;
voteForm.submit(storeVote(props.room.id), {
preserveScroll: true,
onError: () => {
voteForm.value = props.currentPlayer.current_vote ?? '';
},
});
}
function revealCards(): void {
revealForm.submit(reveal(props.room.id), {
preserveScroll: true,
});
}
function startNextRound(): void {
nextRoundForm.submit(nextRound(props.room.id), {
preserveScroll: true,
onSuccess: () => {
voteForm.value = '';
},
});
}
function resetVotes(): void {
resetForm.submit(resetRound(props.room.id), {
preserveScroll: true,
onSuccess: () => {
voteForm.value = '';
},
});
}
async function copyInviteLink(): Promise<void> {
const inviteUrl = new URL('/room/join', window.location.origin);
inviteUrl.searchParams.set('join_code', props.room.join_code);
await navigator.clipboard.writeText(inviteUrl.toString());
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 1_800);
}
function cardLabel(value: string | null): string {
if (value === null) {
return '–';
}
return (
props.room.deck.cards.find((card) => card.key === value)?.label ?? value
);
}
function isCurrentPlayer(player: RoomPlayer): boolean {
return player.id === props.currentPlayer.id;
}
function playerVote(player: RoomPlayer): string | null {
if (isCurrentPlayer(player)) {
return props.currentPlayer.current_vote;
}
return player.vote;
}
</script>
<template>
<Head :title="room.title ?? 'Scrum Poker'" />
<main
class="-m-6 min-h-[calc(100svh-4rem)] bg-slate-900 px-3 py-6 text-white sm:px-6 lg:px-10"
>
<aside
v-if="roomNotice && !roomNoticeDismissed"
class="mx-auto mb-5 flex max-w-6xl flex-col gap-4 rounded-lg border border-dashed border-slate-500 bg-slate-800 p-4 text-sm text-slate-100 sm:flex-row sm:items-center"
role="status"
>
<div class="min-w-0 flex-1">
<p class="font-semibold">
Speichere diesen Raum in deinem Konto
</p>
<p class="mt-1 text-slate-300">
Als Gast wird der Raum nach
{{ roomNotice.guest_retention_days }} Tagen Inaktivität
geschlossen. Nach der Anmeldung bleibt er in deinem
Dashboard gespeichert und die Frist beträgt
{{ roomNotice.account_retention_days }} Tage.
</p>
</div>
<div
class="flex shrink-0 items-center gap-3 font-semibold sm:ml-auto"
>
<Link :href="login()" class="underline underline-offset-4">
Anmelden
</Link>
<Link :href="register()" class="underline underline-offset-4">
Registrieren
</Link>
<button
type="button"
class="ml-1 grid size-8 place-items-center rounded-md text-slate-300 transition hover:bg-slate-700 hover:text-white"
aria-label="Hinweis schließen"
@click="roomNoticeDismissed = true"
>
<X class="size-4" />
</button>
</div>
</aside>
<header
class="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 class="text-2xl font-bold sm:text-3xl">
{{ room.title ?? 'Scrum Poker' }}
</h1>
</div>
<button
type="button"
class="flex w-fit items-center gap-3 rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-left transition hover:bg-slate-700"
@click="copyInviteLink"
>
<span>
<span
class="block text-sm tracking-[0.18em] text-slate-400"
>
Raumcode
</span>
<strong class="font-mono text-lg tracking-[0.2em]">
{{ room.join_code }}
</strong>
</span>
<Check v-if="copied" class="size-5 text-emerald-300" />
<Copy v-else class="size-5 text-slate-300" />
</button>
</header>
<section class="mt-5 w-full">
<div
ref="gameBoard"
class="game-board"
aria-label="Scrum-Poker-Tisch"
>
<div class="poker-table" aria-hidden="true">
<div class="table-highlight"></div>
<div class="table-center">
<span
v-if="!room.revealed"
class="text-sm font-medium text-emerald-50/75"
>
{{
room.players.filter(
(player) => player.has_voted,
).length
}}
von {{ room.players.length }} haben gewählt
</span>
<div
v-else
class="grid grid-cols-2 gap-8 text-emerald-50"
>
<div class="flex flex-col gap-1">
<span class="text-xs text-emerald-100/65">
Durchschnitt
</span>
<strong class="text-xl">
{{ room.statistics?.average ?? '–' }}
</strong>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-emerald-100/65">
Median
</span>
<strong class="text-xl">
{{ room.statistics?.median ?? '–' }}
</strong>
</div>
</div>
</div>
</div>
<div
v-for="paperBall in activePaperBalls"
:key="paperBall.id"
class="paper-ball-flight"
:style="paperBall.style"
aria-hidden="true"
>
<span class="paper-ball"></span>
</div>
<article
v-for="player in positionedPlayers"
:key="player.id"
class="player-seat"
:class="{
'is-paper-ball-hit': hitPlayerIds.includes(player.id),
}"
:style="player.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(player)"
class="own-vote-card"
:class="{ 'is-revealed': room.revealed }"
>
{{ cardLabel(playerVote(player)) }}
</div>
<div
v-else
class="flip-card"
:class="{ 'is-revealed': room.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(player)) }}
</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(player)
"
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(player)"
class="text-cyan-300"
>
Du
</span>
</div>
</div>
<button
v-if="!isCurrentPlayer(player)"
type="button"
class="throw-paper-ball"
:class="{
'is-throwing': throwingAtPlayerId === player.id,
}"
:disabled="throwingAtPlayerId !== null"
:aria-label="`Papierknäuel auf ${player.name} werfen`"
:title="`Papierknäuel auf ${player.name} werfen`"
@click.stop="throwPaperBall(player.id)"
>
<span class="paper-ball-mini" aria-hidden="true"></span>
</button>
</article>
<div v-if="currentPlayer.is_moderator" class="reset-controls">
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-600 bg-slate-800 px-4 py-2.5 font-semibold text-slate-200 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="
resetForm.processing ||
!room.players.some((player) => player.has_voted)
"
@click="resetVotes"
>
<Trash2 class="size-4" />
Votes zurücksetzen
</button>
</div>
<div
v-if="currentPlayer.is_moderator"
class="moderator-controls"
>
<p
v-if="
revealForm.errors.room || nextRoundForm.errors.room
"
class="max-w-64 rounded-lg bg-rose-950/90 px-3 py-2 text-right text-sm text-rose-200 shadow-lg"
role="alert"
>
{{
revealForm.errors.room ?? nextRoundForm.errors.room
}}
</p>
<button
v-if="!room.revealed"
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="revealForm.processing"
@click="revealCards"
>
<Eye class="size-4" />
Karten aufdecken
</button>
<button
v-else
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-slate-100 px-5 py-2.5 font-semibold text-slate-900 transition hover:bg-white disabled:opacity-50"
:disabled="nextRoundForm.processing"
@click="startNextRound"
>
<RotateCcw class="size-4" />
Nächste Runde
</button>
</div>
</div>
</section>
<section class="mx-auto mt-6 max-w-4xl text-center">
<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"
>
{{ room.revealed ? 'Runde beendet' : 'Wähle deine Karte' }}
</h2>
<span class="h-px w-10 bg-white/10"></span>
</div>
<div
v-if="!room.revealed"
class="flex flex-wrap justify-center gap-2.5 sm:gap-3"
>
<button
v-for="card in room.deck.cards"
:key="card.key"
type="button"
class="deck-card"
:class="{ 'is-selected': voteForm.value === card.key }"
:disabled="voteForm.processing"
:aria-pressed="voteForm.value === card.key"
:aria-label="`Karte ${card.label} wählen`"
@click="submitVote(card.key)"
>
<span>{{ card.label }}</span>
</button>
</div>
<p
v-if="voteForm.errors.value"
class="mt-3 text-sm text-rose-300"
role="alert"
>
{{ voteForm.errors.value }}
</p>
</section>
</main>
</template>
<style scoped>
.game-board {
position: relative;
width: 100%;
min-height: max(34rem, calc(100svh - 11rem));
isolation: isolate;
}
.poker-table {
position: absolute;
inset: 20% 15% 21%;
overflow: hidden;
border: 0.65rem solid #513b2d;
border-radius: 50%;
background:
radial-gradient(
circle at 50% 42%,
rgb(16 126 91 / 38%),
transparent 45%
),
linear-gradient(145deg, #136c54, #074c3d);
box-shadow:
inset 0 0 0 0.2rem rgb(255 255 255 / 8%),
inset 0 -1.5rem 3rem rgb(0 0 0 / 22%),
0 1.5rem 3rem rgb(0 0 0 / 35%);
}
.table-highlight {
position: absolute;
inset: 6%;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 50%;
}
.table-center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
}
.player-seat {
position: absolute;
z-index: 2;
width: 8rem;
transform: translate(-50%, -50%);
text-align: center;
}
.player-name {
overflow-wrap: anywhere;
}
.throw-paper-ball {
position: absolute;
top: 0.65rem;
right: 0.35rem;
z-index: 8;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid #64748b;
border-radius: 0.5rem;
background: #334155;
opacity: 0;
transform: translateY(0.25rem) rotate(-8deg);
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease,
background-color 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:focus-visible {
background: #475569;
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.45;
}
.throw-paper-ball.is-throwing {
opacity: 1;
}
.paper-ball-mini,
.paper-ball {
display: block;
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;
}
.paper-ball-mini {
width: 0.95rem;
height: 0.95rem;
transform: rotate(18deg);
}
.paper-ball-flight {
position: absolute;
z-index: 40;
width: 1.65rem;
height: 1.65rem;
pointer-events: none;
will-change: transform;
animation: paper-ball-flight 780ms linear forwards;
}
.paper-ball {
width: 100%;
height: 100%;
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: paper-ball-hit 380ms ease-out;
}
@keyframes paper-ball-flight {
0% {
transform: translate(-50%, -50%) translate(0, 0) rotate(0deg)
scale(0.72);
}
25% {
transform: translate(-50%, -50%)
translate(var(--travel-quarter-x), var(--travel-quarter-y))
rotate(195deg) scale(0.92);
}
50% {
transform: translate(-50%, -50%)
translate(var(--travel-mid-x), var(--travel-mid-y)) rotate(390deg)
scale(1.08);
}
75% {
transform: translate(-50%, -50%)
translate(
var(--travel-three-quarter-x),
var(--travel-three-quarter-y)
)
rotate(585deg) scale(1);
}
100% {
transform: translate(-50%, -50%)
translate(var(--travel-x), var(--travel-y)) rotate(780deg)
scale(0.82);
}
}
@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);
}
}
.player-details-above {
transform: translateY(calc(-100% - 4.5rem));
}
.moderator-controls {
position: absolute;
right: 1.5rem;
bottom: 1.5rem;
top: auto;
left: auto;
z-index: 10;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.reset-controls {
position: absolute;
bottom: 1.5rem;
left: 1.5rem;
z-index: 10;
}
.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;
}
.deck-card {
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 span {
font-size: 1.2rem;
font-weight: 800;
}
.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) {
.game-board {
min-height: max(28rem, calc(100svh - 10rem));
}
.poker-table {
inset: 24% 17% 23%;
border-width: 0.45rem;
}
.player-seat {
width: 6.5rem;
}
.player-details-above {
transform: translateY(calc(-100% - 4rem));
}
.moderator-controls {
right: 1rem;
bottom: 1rem;
}
.reset-controls {
bottom: 1rem;
left: 1rem;
}
.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;
}
.deck-card {
width: 3.3rem;
}
}
@media (hover: none) {
.throw-paper-ball {
opacity: 0.85;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.flip-card-inner,
.deck-card {
transition: none;
}
.paper-ball-flight {
animation-duration: 1ms;
}
.player-seat.is-paper-ball-hit [data-player-avatar] {
animation: none;
}
}
</style>