itpdp/web/src/components/party/question.tsx
2026-05-16 12:51:06 +02:00

268 lines
7.6 KiB
TypeScript

import { useEffect, useState } from "react";
import { Button } from "#/components/ui/button";
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemHeader,
ItemTitle,
} from "#/components/ui/item";
import { Label } from "#/components/ui/label";
import {
Progress,
ProgressLabel,
ProgressValue,
} from "#/components/ui/progress";
import { Section, SectionTitle } from "#/components/ui/section";
import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner";
import { Switch } from "#/components/ui/switch";
import { useParty } from "#/hooks/use-party";
import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
import { useUser } from "#/hooks/user";
import { client } from "#/lib/eden";
type PartyQuestion = NonNullable<
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
>;
function formatTimeLeft(milliseconds: number) {
const clamped = Math.max(0, milliseconds);
const totalSeconds = Math.ceil(clamped / 1000);
const minutes = Math.floor(totalSeconds / 60)
.toString()
.padStart(2, "0");
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
function getQuestionAnnouncement(question: PartyQuestion) {
if (question.type === "numeric") {
return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`;
}
const options = question.options
.map((option, index) => `Option ${index + 1}: ${option}`)
.join(". ");
return `${question.text}. ${options}.`;
}
export function Question() {
const { party, members } = useParty();
const { user } = useUser();
const [now, setNow] = useState(() => Date.now());
const [selected, setSelected] = useState<number | null>(null);
const [selectedValue, setSelectedValue] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const question = party?.data?.currentQuestion;
const spotifyTrackUri =
question?.song?.platform === "spotify" && question.song.platform_id
? `spotify:track:${question.song.platform_id}`
: null;
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null;
const questionAnnouncement = question
? getQuestionAnnouncement(question)
: null;
useEffect(() => {
const timer = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!question) return;
setSelected(null);
setSelectedValue(question.type === "numeric" ? question.range.min : null);
}, [question]);
useEffect(() => {
if (
!questionAnnouncement ||
questionStartTimestamp == null ||
!("speechSynthesis" in window)
) {
return;
}
const utterance = new SpeechSynthesisUtterance(questionAnnouncement);
utterance.rate = 0.95;
utterance.pitch = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
return () => {
window.speechSynthesis.cancel();
};
}, [questionAnnouncement, questionStartTimestamp]);
if (!question)
return (
<Section>
<SectionTitle>Preparing quiz...</SectionTitle>
</Section>
);
const partyId = party.id;
const timeLeft = formatTimeLeft(question.endTimestamp - now);
const answeredCount = Object.keys(party.data.answers).length;
const hasResponded = user ? party.data.answers[user.id] != null : false;
const currentSelection =
selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null);
const currentNumericSelection =
selectedValue ??
(user ? (party.data.answers[user.id]?.selectedValue ?? null) : null);
const numericMin = question.type === "numeric" ? question.range.min : 0;
const numericMax = question.type === "numeric" ? question.range.max : 0;
const progressValue = Math.max(
0,
Math.min(
100,
((question.endTimestamp - now) /
(question.endTimestamp - question.startTimestamp)) *
100,
),
);
async function handleChoiceAnswer(optionIndex: number) {
if (!partyId || hasResponded || isSubmitting) return;
setSelected(optionIndex);
setIsSubmitting(true);
try {
await client.api.party({ partyId }).quiz.response.post({
selected: optionIndex,
});
} finally {
setIsSubmitting(false);
}
}
async function handleNumericAnswer() {
if (!partyId || hasResponded || isSubmitting) return;
const value = selectedValue;
if (value == null) return;
setIsSubmitting(true);
try {
await client.api.party({ partyId }).quiz.response.post({
selected: value,
});
} finally {
setIsSubmitting(false);
}
}
return (
<Section>
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{question.text}</ItemTitle>
<ItemDescription>{question.points} points</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Progress value={progressValue}>
<ProgressLabel>
{hasResponded ? "You responded" : "Waiting on your response"}
</ProgressLabel>
<ProgressValue>{() => timeLeft}</ProgressValue>
</Progress>
</ItemHeader>
<ItemDescription>
{answeredCount} / {members.length} responses
</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemContent>
<ItemHeader>
<Label htmlFor="spotify-playback" className="cursor-pointer">
Audio playback
</Label>
<Switch
id="spotify-playback"
checked={spotifyEnabled}
onCheckedChange={(checked) =>
void setSpotifyEnabled(checked === true)
}
/>
</ItemHeader>
<ItemDescription>
{question.hideSongTitle
? "Listen closely and guess the song."
: (question.song?.name ??
"This question has no associated song.")}
</ItemDescription>
</ItemContent>
</Item>
{question.type === "numeric" ? (
<Item variant="muted">
<ItemContent>
<div className="space-y-3">
<Slider
min={numericMin}
max={numericMax}
step={1}
value={
currentNumericSelection != null
? [currentNumericSelection]
: undefined
}
onValueChange={(value) =>
setSelectedValue(
typeof value === "number" ? value : (value[0] ?? null),
)
}
/>
<div className="text-sm text-muted-foreground">
Exact value:{" "}
<span className="font-medium text-foreground">
{currentNumericSelection ?? question.correct}
</span>
</div>
</div>
</ItemContent>
<Button
disabled={hasResponded || isSubmitting || selectedValue == null}
onClick={() => void handleNumericAnswer()}
>
{isSubmitting ? <Spinner /> : "Answer"}
</Button>
</Item>
) : (
question.options?.map((option, index) => {
const isSelected = currentSelection === index;
return (
<Item key={option} variant={isSelected ? "outline" : "default"}>
<ItemContent>
<ItemHeader>
<ItemTitle>
{index + 1}. {option}
</ItemTitle>
{isSelected && <ItemDescription>Selected</ItemDescription>}
</ItemHeader>
</ItemContent>
<Button
variant={isSelected ? "secondary" : "default"}
disabled={hasResponded || isSubmitting}
onClick={() => handleChoiceAnswer(index)}
>
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
</Button>
</Item>
);
})
)}
</ItemGroup>
</Section>
);
}