"use client"; import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityRenderer } from "@/components/activities"; import { celebrate } from "@/lib/confetti"; import { ApiError, generateDeck } from "@/lib/client-api"; import { materialiseActivities } from "@/lib/deck-ops"; import { useI18n } from "@/lib/i18n"; import type { Activity, Deck } from "@/lib/schemas"; import { recordAttempt, upsertDeck } from "@/lib/storage"; import { cn } from "@/lib/utils"; import { BoltIcon, FlameIcon, XIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; import { ResultsScreen } from "./ResultsScreen"; export type AttemptResult = { correct: boolean; userAnswer?: string }; export function StudyFeed({ deck }: { deck: Deck }) { const { t } = useI18n(); const [currentDeck, setCurrentDeck] = useState(deck); const [queue, setQueue] = useState(deck.activities); const [index, setIndex] = useState(0); const [results, setResults] = useState>({}); const [xp, setXp] = useState(0); const [streak, setStreak] = useState(0); const [bestStreak, setBestStreak] = useState(0); const [retryMode, setRetryMode] = useState(false); const [regenerating, setRegenerating] = useState(false); const [regenerateError, setRegenerateError] = useState(null); /** Bumped on every restart so the activity components remount fresh. */ const [runId, setRunId] = useState(0); const scrollRef = useRef(null); const recordedRef = useRef(false); const total = queue.length; const slideCount = total + 1; // + results slide const goTo = useCallback((target: number) => { const container = scrollRef.current; if (!container) return; const clamped = Math.max(0, Math.min(target, slideCount - 1)); container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" }); }, [slideCount]); const next = useCallback(() => goTo(index + 1), [goTo, index]); // Track which slide is visible (works for wheel, touch and programmatic scroll). useEffect(() => { const container = scrollRef.current; if (!container) return; let frame = 0; const onScroll = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { const height = container.clientHeight || 1; const current = Math.round(container.scrollTop / height); setIndex((previous) => (previous === current ? previous : current)); }); }; container.addEventListener("scroll", onScroll, { passive: true }); return () => { container.removeEventListener("scroll", onScroll); cancelAnimationFrame(frame); }; }, []); // Keyboard navigation. useEffect(() => { const onKey = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") { event.preventDefault(); next(); } else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") { event.preventDefault(); goTo(index - 1); } else if (event.key === "Home") { event.preventDefault(); goTo(0); } else if (event.key === "End") { event.preventDefault(); goTo(slideCount - 1); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [goTo, index, next, slideCount]); const finished = index >= total; useEffect(() => { if (!finished) return; if (!retryMode && !recordedRef.current) { recordedRef.current = true; const answered = Object.values(results); const correct = answered.filter((entry) => entry.correct).length; const score = total > 0 ? (correct / total) * 100 : 0; // Only a run with at least one answer counts. Without this, the scroll // anchoring shift when a regenerated deck swaps in could fire a bogus // 0% attempt before the feed had snapped back to the first slide. if (answered.length > 0) { recordAttempt(currentDeck.id, { scorePercent: score, correct, total }); void celebrate("big"); } } }, [finished, results, total, currentDeck.id, retryMode]); const handleAnswered = useCallback( (activity: Activity, correct: boolean, meta?: { userAnswer?: string }) => { setResults((current) => { if (current[activity.id]) return current; // first answer counts return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } }; }); if (correct) { const nextStreak = streak + 1; setStreak(nextStreak); setBestStreak((best) => Math.max(best, nextStreak)); setXp((value) => value + activity.xp); if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal"); } else { setStreak(0); } }, [streak], ); const missed = useMemo( () => queue.filter((activity) => !results[activity.id]?.correct), [queue, results], ); const correctCount = useMemo( () => queue.filter((activity) => results[activity.id]?.correct).length, [queue, results], ); const restart = useCallback( (activities: Activity[], retry: boolean) => { setQueue(activities); setResults({}); setStreak(0); setRetryMode(retry); recordedRef.current = retry; // don't overwrite the best score with a retry run // A new run: remount every activity (fresh state) and jump to the top // *before* the new slides render, so scroll anchoring can't hold the // old results position. const container = scrollRef.current; if (container) container.scrollTop = 0; setRunId((value) => value + 1); setIndex(0); }, [], ); // Whenever a run starts (including the first render), snap back to slide 1. useEffect(() => { const container = scrollRef.current; if (container) container.scrollTop = 0; }, [runId]); /** Rebuild the deck from its stored source notes and start it over. */ const regenerate = useCallback(async () => { if (regenerating) return; const source = currentDeck.sourceText?.trim(); if (!source) { setRegenerateError(t("deck.noSourceRegenerate")); return; } setRegenerating(true); setRegenerateError(null); try { const { deck: generated } = await generateDeck({ text: source, difficulty: currentDeck.difficulty, language: currentDeck.language, count: Math.max(8, currentDeck.activities.length), }); const activities = materialiseActivities(generated.activities, currentDeck.difficulty); const updated: Deck = { ...currentDeck, title: generated.title || currentDeck.title, subject: generated.subject ?? currentDeck.subject, summary: generated.summary ?? currentDeck.summary, language: generated.language ?? currentDeck.language, activities, attempts: 0, bestScore: undefined, updatedAt: Date.now(), }; upsertDeck(updated); setCurrentDeck(updated); restart(activities, false); } catch (error) { setRegenerateError(error instanceof ApiError ? error.message : t("deck.couldNotRegenerate")); } finally { setRegenerating(false); } }, [currentDeck, regenerating, restart, t]); const progressValue = Math.min(index + 1, slideCount); const progressMax = slideCount; return (
{/* Chrome ---------------------------------------------------- */}
= 3 ? "tint-peach" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]", )} title={t("feed.streak", { n: streak })} aria-label={t("feed.streak", { n: streak })} > {streak} {xp}
{currentDeck.title} {retryMode ? t("feed.retry") : ""} {Math.min(index + 1, total)}/{total}
{/* Feed ------------------------------------------------------ */}
{queue.map((activity, activityIndex) => (
handleAnswered(activity, correct, meta)} onNext={() => goTo(activityIndex + 1)} />
))}
restart(missed, true)} onRestart={() => restart(currentDeck.activities, false)} onRegenerate={regenerate} regenerating={regenerating} regenerateError={regenerateError} />
{/* Swipe hint on the very first screen ----------------------- */} {index === 0 && Object.keys(results).length === 0 ? ( {t("feed.swipeUp")} ) : null} {/* Desktop next/prev buttons --------------------------------- */}
{index + 1} / {slideCount}
); }