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

664 lines
19 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, useForm, usePoll } from '@inertiajs/vue3';
import {
Check,
CircleUserRound,
Copy,
Crown,
Eye,
RotateCcw,
Trash2,
} from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import type { CSSProperties } from 'vue';
import { nextRound, reset as resetRound, reveal } from '@/routes/room';
import { store as storeVote } from '@/routes/room/vote';
type PokerCard = {
key: string;
label: string;
};
type Player = {
id: number;
name: string;
is_moderator: boolean;
has_voted: boolean;
vote: string | null;
};
type SeatStyle = CSSProperties & {
'--card-x': string;
'--card-y': string;
};
const props = defineProps<{
room: {
id: string;
title: string | null;
join_code: string;
current_round: number;
revealed: boolean;
state_version: number;
deck: {
name: string;
cards: PokerCard[];
};
players: Player[];
};
currentPlayer: {
id: number;
name: string;
is_moderator: boolean;
current_vote: string | null;
};
}>();
const voteForm = useForm({
value: props.currentPlayer.current_vote ?? '',
});
const revealForm = useForm<{ room?: string }>({});
const nextRoundForm = useForm<{ room?: string }>({});
const resetForm = useForm({});
const copied = ref(false);
usePoll(2_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 = 88;
return {
...player,
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: Player): boolean {
return player.id === props.currentPlayer.id;
}
function playerVote(player: Player): 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"
>
<header
class="mx-auto flex max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p
class="mb-1 text-xs font-semibold tracking-[0.22em] text-cyan-300 uppercase"
>
Runde {{ room.current_round }} · {{ room.deck.name }}
</p>
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">
{{ room.title ?? 'Scrum Poker' }}
</h1>
</div>
<button
type="button"
class="flex w-fit items-center gap-3 rounded-2xl 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-[10px] tracking-[0.18em] text-slate-400 uppercase"
>
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 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>
<span
v-else
class="text-sm font-semibold text-amber-200"
>
Karten aufgedeckt
</span>
</div>
</div>
<article
v-for="player in positionedPlayers"
:key="player.id"
class="player-seat"
:style="player.seatStyle"
>
<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"
>
<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="mt-2 flex flex-col items-center gap-1">
<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>
</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;
}
.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: 50%;
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;
}
.moderator-controls {
right: 1rem;
bottom: 1rem;
}
.reset-controls {
bottom: 1rem;
left: 1rem;
}
.seat-card {
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 (prefers-reduced-motion: reduce) {
.flip-card-inner,
.deck-card {
transition: none;
}
}
</style>