Files
entscheidomat/components/tools/magic-8-ball.tsx
2026-07-24 16:33:44 +02:00

267 lines
9.9 KiB
TypeScript
Raw Permalink 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.
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Pencil, Plus, Sparkles, X } from "lucide-react";
import { gsap } from "@/lib/gsap";
import { popConfetti } from "@/lib/confetti";
import { useSound } from "@/components/providers/sound-provider";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const DEFAULT_ANSWERS = [
"Ja, definitiv.",
"Ohne Zweifel.",
"Sehr wahrscheinlich.",
"Sieht gut aus.",
"Frag später nochmal.",
"Kann ich jetzt nicht sagen.",
"Konzentrier dich und frag erneut.",
"Rechne nicht damit.",
"Meine Antwort: Nein.",
"Eher nicht.",
];
const MIN_ANSWERS = 2;
const MAX_ANSWERS = 20;
export default function Magic8Ball() {
const [answers, setAnswers] = useLocalStorage<string[]>(
"ball-answers",
DEFAULT_ANSWERS,
);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
const [shaking, setShaking] = useState(false);
const [editing, setEditing] = useState(false);
const ballRef = useRef<HTMLDivElement>(null);
const answerInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const justAddedRef = useRef(false);
const { play } = useSound();
const reduced = usePrefersReducedMotion();
const validAnswers = answers.map((a) => a.trim()).filter(Boolean);
const shake = () => {
if (shaking || validAnswers.length === 0) return;
const pick =
validAnswers[Math.floor(Math.random() * validAnswers.length)];
setShaking(true);
setAnswer(null);
play("whoosh");
const reveal = () => {
setAnswer(pick);
setShaking(false);
play("pop");
popConfetti();
};
if (reduced) {
gsap.delayedCall(0.25, reveal);
return;
}
const tl = gsap.timeline({ onComplete: reveal });
tl.to(ballRef.current, {
x: -18,
rotation: -9,
duration: 0.08,
ease: "power1.inOut",
})
.to(ballRef.current, {
x: 16,
rotation: 8,
duration: 0.08,
ease: "power1.inOut",
})
.repeat(4)
.to(ballRef.current, {
x: 0,
rotation: 0,
duration: 0.22,
ease: "power2.out",
});
};
const updateAnswer = (i: number, value: string) =>
setAnswers((list) => list.map((a, idx) => (idx === i ? value : a)));
const addAnswer = () => {
if (answers.length >= MAX_ANSWERS) return;
setAnswers((list) => [...list, ""]);
justAddedRef.current = true;
};
useEffect(() => {
if (!justAddedRef.current) return;
justAddedRef.current = false;
const last = answerInputRefs.current[answers.length - 1];
last?.scrollIntoView({ block: "nearest", behavior: "smooth" });
last?.focus();
}, [answers.length]);
const removeAnswer = (i: number) => {
if (answers.length <= MIN_ANSWERS) return;
setAnswers((list) => list.filter((_, idx) => idx !== i));
};
return (
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
{/* Bühne */}
<div className="flex flex-col items-center">
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
maxLength={80}
placeholder="Deine Frage (optional) …"
aria-label="Deine Frage an den 8-Ball"
className="w-full max-w-sm rounded-full border border-line bg-surface px-5 py-3 text-center text-sm font-medium shadow-soft outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
<div ref={ballRef} className="mt-6">
<button
type="button"
onClick={shake}
aria-label="8-Ball schütteln"
className="ball-body relative flex h-64 w-64 cursor-pointer items-center justify-center rounded-full transition-transform duration-200 hover:scale-[1.03] active:scale-95 sm:h-72 sm:w-72"
>
<div className="ball-window relative flex h-32 w-32 items-center justify-center overflow-hidden rounded-full sm:h-36 sm:w-36">
{/* Prisma-Dreieck */}
<div
aria-hidden
className="absolute inset-4 bg-primary/60"
style={{
clipPath: "polygon(50% 6%, 94% 90%, 6% 90%)",
}}
/>
<div
className="relative z-10 flex h-full w-full items-center justify-center px-7"
aria-live="polite"
>
<AnimatePresence mode="wait">
{answer ? (
<motion.p
key={answer}
initial={{ opacity: 0, scale: 0.7, filter: "blur(8px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(6px)" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="text-center font-display text-sm font-semibold leading-snug text-[#dde5dd]"
>
{answer}
</motion.p>
) : (
<motion.p
key="eight"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="font-display text-4xl font-semibold text-[#dde5dd]"
>
8
</motion.p>
)}
</AnimatePresence>
</div>
</div>
</button>
</div>
<motion.button
type="button"
onClick={shake}
disabled={shaking || validAnswers.length === 0}
whileTap={{ scale: 0.95 }}
className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
>
<Sparkles className="h-5 w-5" />
{shaking ? "Schüttelt …" : "8-Ball schütteln"}
</motion.button>
<p className="mt-3 text-xs text-ink-soft">
{question.trim()
? `${question.trim()}`
: "Stell eine Ja/Nein-Frage oder lass dich einfach überraschen."}
</p>
</div>
{/* Antworten-Editor */}
<div className="rounded-3xl border border-line bg-cream/70 p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
Antworten ({validAnswers.length})
</h3>
<button
type="button"
onClick={() => setEditing((e) => !e)}
aria-expanded={editing}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-colors ${
editing
? "bg-primary text-white"
: "bg-surface text-ink-soft ring-1 ring-line hover:text-primary"
}`}
>
<Pencil className="h-3.5 w-3.5" />
{editing ? "Fertig" : "Bearbeiten"}
</button>
</div>
<AnimatePresence initial={false}>
{editing && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="mt-4 max-h-[260px] space-y-2 overflow-y-auto pr-1">
{answers.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<input
ref={(el) => {
answerInputRefs.current[i] = el;
}}
value={a}
onChange={(e) => updateAnswer(i, e.target.value)}
maxLength={60}
placeholder={`Antwort ${i + 1}`}
aria-label={`Antwort ${i + 1}`}
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
/>
<button
type="button"
onClick={() => removeAnswer(i)}
disabled={answers.length <= MIN_ANSWERS}
aria-label={`Antwort ${i + 1} entfernen`}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-xl text-ink-soft transition-colors hover:bg-clay-soft hover:text-clay disabled:cursor-not-allowed disabled:opacity-30"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={addAnswer}
disabled={answers.length >= MAX_ANSWERS}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-primary/50 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Plus className="h-4 w-4" />
Antwort hinzufügen
</button>
</motion.div>
)}
</AnimatePresence>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
Der Ball sagt, was du ihm vorgibst von klassisch bis fies.{" "}
{MIN_ANSWERS}{MAX_ANSWERS} Antworten, automatisch gespeichert.
</p>
</div>
</div>
);
}