Download src/components/chapter-loop/SoundWavesChapterLoop.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 20.2 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/chapter-loop/SoundWavesChapterLoop.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/chapter-loop/SoundWavesChapterLoop.tsx
-
curl -L -o SoundWavesChapterLoop.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/chapter-loop/SoundWavesChapterLoop.tsx
20.2 kB
| "use client"; | |
| import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; | |
| import { | |
| CHAPTER_META, | |
| FLASHCARDS, | |
| FORMULA_LOCK, | |
| NOTE_SECTIONS, | |
| QUIZ, | |
| matchChatReply, | |
| type Flashcard, | |
| type QuizQuestion, | |
| } from "@/content/sslc/physics/sound-waves/oneshot"; | |
| const display = "var(--font-docdoe-display), Archivo, ui-sans-serif, system-ui, sans-serif"; | |
| const body = "var(--font-docdoe-sans), Onest, ui-sans-serif, system-ui, sans-serif"; | |
| type StepId = "watch" | "notes" | "chat" | "test"; | |
| const STEPS: { id: StepId; label: string }[] = [ | |
| { id: "watch", label: "Watch" }, | |
| { id: "notes", label: "Notes" }, | |
| { id: "chat", label: "Chat" }, | |
| { id: "test", label: "Test" }, | |
| ]; | |
| const WATCH_UNLOCK_SEC = 20; | |
| const LS_DONE = "docdoe.preview.sound-waves.chapterDone"; | |
| type ChatMsg = { role: "tutor" | "student"; text: string }; | |
| const CHIPS = ["Explain again", "Give example", "Exam trap", "Quiz me"] as const; | |
| export function SoundWavesChapterLoop() { | |
| const [step, setStep] = useState<StepId>("watch"); | |
| const [watchedEnough, setWatchedEnough] = useState(false); | |
| const [founderSkip, setFounderSkip] = useState(false); | |
| const notesUnlocked = watchedEnough || founderSkip; | |
| const videoRef = useRef<HTMLVideoElement | null>(null); | |
| const onTimeUpdate = useCallback(() => { | |
| const v = videoRef.current; | |
| if (!v) return; | |
| if (v.currentTime >= WATCH_UNLOCK_SEC) setWatchedEnough(true); | |
| }, []); | |
| const go = (id: StepId) => { | |
| if (id !== "watch" && !notesUnlocked && id === "notes") return; | |
| if ((id === "notes" || id === "chat" || id === "test") && !notesUnlocked) return; | |
| // Allow chat/test only after notes unlocked (same gate as notes for prototype simplicity) | |
| setStep(id); | |
| }; | |
| const unlockAndNotes = () => { | |
| setFounderSkip(true); | |
| setStep("notes"); | |
| }; | |
| return ( | |
| <div className="min-h-svh bg-[#10211c] text-[#f2f4f1]" style={{ fontFamily: body }}> | |
| <div className="border-b border-white/10 bg-[#0c1a16] px-4 py-3 sm:px-6"> | |
| <div className="mx-auto max-w-[720px]"> | |
| <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| Sound Waves chapter loop preview | |
| </p> | |
| <p className="mt-1 text-[12px] leading-5 text-[#f2f4f1]/55"> | |
| Sound Waves chapter loop preview · /home untouched | |
| </p> | |
| </div> | |
| </div> | |
| <div className="mx-auto w-full max-w-[720px] px-4 py-5 pb-24 sm:px-6 sm:py-8"> | |
| <header className="mb-5"> | |
| <p className="text-[12px] font-medium text-[#f2f4f1]/55 sm:text-[13px]"> | |
| {CHAPTER_META.subject} · {CHAPTER_META.board} | |
| </p> | |
| <h1 | |
| className="mt-1.5 text-[32px] font-semibold leading-none tracking-[-0.04em] text-[#f2f4f1] sm:text-[44px]" | |
| style={{ fontFamily: display }} | |
| > | |
| {CHAPTER_META.title} | |
| </h1> | |
| </header> | |
| <nav | |
| className="mb-6 flex gap-1 overflow-x-auto rounded-[14px] border border-white/10 bg-white/[0.04] p-1" | |
| aria-label="Chapter steps" | |
| > | |
| {STEPS.map((s, i) => { | |
| const locked = s.id !== "watch" && !notesUnlocked; | |
| const active = step === s.id; | |
| return ( | |
| <button | |
| key={s.id} | |
| type="button" | |
| disabled={locked} | |
| onClick={() => go(s.id)} | |
| className={[ | |
| "min-h-10 flex-1 rounded-[10px] px-2 text-[12px] font-semibold sm:text-[13px]", | |
| active | |
| ? "bg-[#c6e04a] text-[#10211c]" | |
| : locked | |
| ? "cursor-not-allowed text-[#f2f4f1]/30" | |
| : "text-[#f2f4f1]/70 hover:bg-white/[0.06]", | |
| ].join(" ")} | |
| > | |
| <span className="tabular-nums opacity-70">{i + 1}.</span> {s.label} | |
| </button> | |
| ); | |
| })} | |
| </nav> | |
| {step === "watch" ? ( | |
| <WatchStep | |
| videoRef={videoRef} | |
| onTimeUpdate={onTimeUpdate} | |
| notesUnlocked={notesUnlocked} | |
| onOpenNotes={() => setStep("notes")} | |
| onFounderSkip={unlockAndNotes} | |
| /> | |
| ) : null} | |
| {step === "notes" ? <NotesStep onContinue={() => setStep("chat")} /> : null} | |
| {step === "chat" ? <ChatStep onContinue={() => setStep("test")} /> : null} | |
| {step === "test" ? <TestStep /> : null} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function WatchStep({ | |
| videoRef, | |
| onTimeUpdate, | |
| notesUnlocked, | |
| onOpenNotes, | |
| onFounderSkip, | |
| }: { | |
| videoRef: RefObject<HTMLVideoElement | null>; | |
| onTimeUpdate: () => void; | |
| notesUnlocked: boolean; | |
| onOpenNotes: () => void; | |
| onFounderSkip: () => void; | |
| }) { | |
| return ( | |
| <section className="space-y-4"> | |
| <p className="text-[14px] leading-6 text-[#f2f4f1]/75"> | |
| Watch the class first. Notes open after. | |
| </p> | |
| <div className="overflow-hidden rounded-[16px] border border-white/10 bg-black"> | |
| <video | |
| ref={videoRef} | |
| className="aspect-video w-full bg-black" | |
| controls | |
| playsInline | |
| preload="metadata" | |
| onTimeUpdate={onTimeUpdate} | |
| onPlay={onTimeUpdate} | |
| > | |
| <source src={CHAPTER_META.videoSrc} type="video/mp4" /> | |
| <track | |
| kind="captions" | |
| src={CHAPTER_META.captionsSrc} | |
| srcLang="en" | |
| label="English" | |
| default | |
| /> | |
| </video> | |
| </div> | |
| <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| <button | |
| type="button" | |
| onClick={onFounderSkip} | |
| className="text-left text-[12px] font-medium text-[#f2f4f1]/45 underline-offset-2 hover:text-[#f2f4f1]/70 hover:underline" | |
| > | |
| I've watched — open notes | |
| </button> | |
| <button | |
| type="button" | |
| disabled={!notesUnlocked} | |
| onClick={onOpenNotes} | |
| className={[ | |
| "inline-flex min-h-11 items-center justify-center rounded-full px-5 text-[14px] font-semibold", | |
| notesUnlocked | |
| ? "bg-[#c6e04a] text-[#10211c]" | |
| : "cursor-not-allowed bg-white/10 text-[#f2f4f1]/35", | |
| ].join(" ")} | |
| > | |
| Open notes | |
| </button> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| function NotesStep({ onContinue }: { onContinue: () => void }) { | |
| return ( | |
| <section className="space-y-4"> | |
| <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| One-shot notes | |
| </p> | |
| <div className="max-h-[70vh] space-y-4 overflow-y-auto rounded-[16px] bg-[#f4f3ee] p-4 text-[#10211c] sm:p-5"> | |
| {NOTE_SECTIONS.map((s) => ( | |
| <article | |
| key={s.n} | |
| className="rounded-[14px] border border-[#10211c]/10 bg-[#f2f4f1] p-4" | |
| > | |
| <h2 | |
| className="text-[17px] font-semibold tracking-[-0.02em]" | |
| style={{ fontFamily: display }} | |
| > | |
| <span className="tabular-nums text-[#10211c]/45">{s.n}.</span> {s.title} | |
| </h2> | |
| <p className="mt-2 text-[14px] leading-6 text-[#10211c]/85">{s.concept}</p> | |
| {s.formula ? ( | |
| <pre className="mt-3 overflow-x-auto rounded-[10px] border border-[#10211c]/12 bg-[#10211c] px-3 py-2.5 font-mono text-[12px] leading-5 text-[#c6e04a] sm:text-[13px]"> | |
| {s.formula} | |
| </pre> | |
| ) : null} | |
| {s.trap ? ( | |
| <p className="mt-3 rounded-[10px] border border-amber-700/25 bg-amber-50 px-3 py-2 text-[13px] leading-5 text-amber-950"> | |
| <span className="font-semibold">Trap · </span> | |
| {s.trap} | |
| </p> | |
| ) : null} | |
| {s.example ? ( | |
| <p className="mt-3 text-[13px] leading-5 text-[#10211c]/70"> | |
| <span className="font-semibold text-[#10211c]">Example · </span> | |
| {s.example} | |
| </p> | |
| ) : null} | |
| </article> | |
| ))} | |
| <article className="rounded-[14px] border-2 border-[#10211c] bg-[#10211c] p-4 text-[#f2f4f1]"> | |
| <h2 | |
| className="text-[15px] font-semibold uppercase tracking-[0.12em] text-[#c6e04a]" | |
| style={{ fontFamily: display }} | |
| > | |
| Chapter formula lock | |
| </h2> | |
| <p className="mt-1 text-[12px] text-[#f2f4f1]/55"> | |
| Write these without looking. | |
| </p> | |
| <ul className="mt-3 space-y-2"> | |
| {FORMULA_LOCK.map((line) => ( | |
| <li | |
| key={line} | |
| className="rounded-[8px] bg-white/[0.06] px-3 py-2 font-mono text-[13px] text-[#c6e04a]" | |
| > | |
| {line} | |
| </li> | |
| ))} | |
| </ul> | |
| </article> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onContinue} | |
| className="inline-flex min-h-11 w-full items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c] sm:w-auto" | |
| > | |
| Continue to study chat | |
| </button> | |
| </section> | |
| ); | |
| } | |
| function ChatStep({ onContinue }: { onContinue: () => void }) { | |
| const [messages, setMessages] = useState<ChatMsg[]>([ | |
| { | |
| role: "tutor", | |
| text: `Locked to ${CHAPTER_META.contextLock}. Ask a trap, a formula, or tap a chip.`, | |
| }, | |
| ]); | |
| const [draft, setDraft] = useState(""); | |
| const bottomRef = useRef<HTMLDivElement | null>(null); | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const send = (text: string) => { | |
| const cleaned = text.trim(); | |
| if (!cleaned) return; | |
| const reply = matchChatReply(cleaned); | |
| setMessages((m) => [...m, { role: "student", text: cleaned }, { role: "tutor", text: reply }]); | |
| setDraft(""); | |
| }; | |
| return ( | |
| <section className="space-y-3"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| Study chat | |
| </p> | |
| <span className="rounded-full border border-white/12 bg-white/[0.06] px-2.5 py-1 text-[11px] font-medium text-[#f2f4f1]/70"> | |
| {CHAPTER_META.contextLock} | |
| </span> | |
| </div> | |
| <div className="flex max-h-[52vh] flex-col overflow-hidden rounded-[16px] border border-white/10 bg-[#0c1a16]"> | |
| <div className="flex-1 space-y-3 overflow-y-auto p-3 sm:p-4"> | |
| {messages.map((m, i) => ( | |
| <div | |
| key={`${i}-${m.role}`} | |
| className={[ | |
| "max-w-[92%] whitespace-pre-wrap rounded-[14px] px-3 py-2.5 text-[14px] leading-6", | |
| m.role === "tutor" | |
| ? "bg-[#f2f4f1] text-[#10211c]" | |
| : "ml-auto bg-white/[0.08] text-[#f2f4f1]", | |
| ].join(" ")} | |
| > | |
| {m.role === "tutor" ? ( | |
| <p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[#10211c]/45"> | |
| Tutor | |
| </p> | |
| ) : null} | |
| {m.text} | |
| </div> | |
| ))} | |
| <div ref={bottomRef} /> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5 border-t border-white/10 px-3 py-2"> | |
| {CHIPS.map((c) => ( | |
| <button | |
| key={c} | |
| type="button" | |
| onClick={() => send(c)} | |
| className="rounded-full border border-white/12 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-[#f2f4f1]/75" | |
| > | |
| {c} | |
| </button> | |
| ))} | |
| </div> | |
| <form | |
| className="flex gap-2 border-t border-white/10 p-3" | |
| onSubmit={(e) => { | |
| e.preventDefault(); | |
| send(draft); | |
| }} | |
| > | |
| <input | |
| value={draft} | |
| onChange={(e) => setDraft(e.target.value)} | |
| placeholder="Ask about Sound Waves…" | |
| className="min-h-11 flex-1 rounded-full border border-white/12 bg-white/[0.06] px-4 text-[14px] text-[#f2f4f1] outline-none placeholder:text-[#f2f4f1]/35 focus:border-[#c6e04a]/50" | |
| /> | |
| <button | |
| type="submit" | |
| className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-[#c6e04a] px-4 text-[13px] font-semibold text-[#10211c]" | |
| > | |
| Send | |
| </button> | |
| </form> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={onContinue} | |
| className="inline-flex min-h-11 w-full items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c] sm:w-auto" | |
| > | |
| Continue to exam test | |
| </button> | |
| </section> | |
| ); | |
| } | |
| function TestStep() { | |
| const [qi, setQi] = useState(0); | |
| const [selected, setSelected] = useState<number | null>(null); | |
| const [revealed, setRevealed] = useState(false); | |
| const [score, setScore] = useState(0); | |
| const [phase, setPhase] = useState<"quiz" | "score" | "cards">("quiz"); | |
| const [cardIndex, setCardIndex] = useState(0); | |
| const [flipped, setFlipped] = useState(false); | |
| const [knowCount, setKnowCount] = useState(0); | |
| const [done, setDone] = useState(() => { | |
| if (typeof window === "undefined") return false; | |
| try { | |
| return window.localStorage.getItem(LS_DONE) === "1"; | |
| } catch { | |
| return false; | |
| } | |
| }); | |
| const q: QuizQuestion = QUIZ[qi]; | |
| const card: Flashcard = FLASHCARDS[cardIndex]; | |
| const total = QUIZ.length; | |
| const submitChoice = (idx: number) => { | |
| if (revealed) return; | |
| setSelected(idx); | |
| setRevealed(true); | |
| if (idx === q.answerIndex) setScore((s) => s + 1); | |
| }; | |
| const nextQ = () => { | |
| if (qi + 1 >= total) { | |
| setPhase("score"); | |
| return; | |
| } | |
| setQi((i) => i + 1); | |
| setSelected(null); | |
| setRevealed(false); | |
| }; | |
| const retryType = () => { | |
| setSelected(null); | |
| setRevealed(false); | |
| if (selected === q.answerIndex) setScore((s) => Math.max(0, s - 1)); | |
| }; | |
| const markDone = () => { | |
| try { | |
| localStorage.setItem(LS_DONE, "1"); | |
| setDone(true); | |
| } catch { | |
| setDone(true); | |
| } | |
| }; | |
| const advanceCard = (know: boolean) => { | |
| if (know) setKnowCount((c) => c + 1); | |
| setFlipped(false); | |
| if (cardIndex + 1 >= FLASHCARDS.length) { | |
| setCardIndex(0); | |
| return; | |
| } | |
| setCardIndex((i) => i + 1); | |
| }; | |
| const scoreLine = `${score} / ${total}`; | |
| if (phase === "quiz") { | |
| return ( | |
| <section className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| Exam test | |
| </p> | |
| <span className="text-[12px] tabular-nums text-[#f2f4f1]/55"> | |
| {qi + 1} / {total} | |
| </span> | |
| </div> | |
| <div className="rounded-[16px] bg-[#f4f3ee] p-4 text-[#10211c] sm:p-5"> | |
| <p className="text-[15px] font-semibold leading-6" style={{ fontFamily: display }}> | |
| {q.prompt} | |
| </p> | |
| <div className="mt-4 space-y-2"> | |
| {q.choices.map((c, idx) => { | |
| const isSel = selected === idx; | |
| const isAns = idx === q.answerIndex; | |
| let cls = | |
| "w-full rounded-[12px] border border-[#10211c]/12 bg-[#f2f4f1] px-3 py-3 text-left text-[14px] leading-5"; | |
| if (revealed && isAns) cls += " border-[#10211c] bg-[#c6e04a]/35"; | |
| if (revealed && isSel && !isAns) cls += " border-red-700/40 bg-red-50"; | |
| if (!revealed) cls += " hover:border-[#10211c]/35"; | |
| return ( | |
| <button | |
| key={c} | |
| type="button" | |
| disabled={revealed} | |
| onClick={() => submitChoice(idx)} | |
| className={cls} | |
| > | |
| {c} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {revealed ? ( | |
| <div className="mt-4 space-y-3"> | |
| <p className="rounded-[10px] border border-[#10211c]/12 bg-white px-3 py-2 text-[13px] leading-5"> | |
| <span className="font-semibold">Fix · </span> | |
| {q.fix} | |
| </p> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| type="button" | |
| onClick={retryType} | |
| className="min-h-10 rounded-full border border-[#10211c]/20 px-4 text-[13px] font-semibold" | |
| > | |
| Retry this type | |
| </button> | |
| <button | |
| type="button" | |
| onClick={nextQ} | |
| className="min-h-10 rounded-full bg-[#10211c] px-4 text-[13px] font-semibold text-[#c6e04a]" | |
| > | |
| {qi + 1 >= total ? "See score" : "Next"} | |
| </button> | |
| </div> | |
| </div> | |
| ) : null} | |
| </div> | |
| </section> | |
| ); | |
| } | |
| if (phase === "score") { | |
| return ( | |
| <section className="space-y-4"> | |
| <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| Score | |
| </p> | |
| <div className="rounded-[16px] bg-[#f4f3ee] p-5 text-[#10211c]"> | |
| <p className="text-[40px] font-semibold tracking-[-0.04em]" style={{ fontFamily: display }}> | |
| {scoreLine} | |
| </p> | |
| <p className="mt-2 text-[14px] text-[#10211c]/70"> | |
| Next: flashcards from the chapter formula lock. | |
| </p> | |
| <button | |
| type="button" | |
| onClick={() => setPhase("cards")} | |
| className="mt-4 inline-flex min-h-11 items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c]" | |
| > | |
| Open flashcards | |
| </button> | |
| </div> | |
| </section> | |
| ); | |
| } | |
| return ( | |
| <section className="space-y-4"> | |
| <div className="flex items-center justify-between"> | |
| <p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]"> | |
| Formula flashcards | |
| </p> | |
| <span className="text-[12px] tabular-nums text-[#f2f4f1]/55"> | |
| {cardIndex + 1} / {FLASHCARDS.length} · know {knowCount} | |
| </span> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => setFlipped((f) => !f)} | |
| className="flex min-h-[180px] w-full flex-col items-center justify-center rounded-[16px] border border-white/10 bg-[#f4f3ee] px-4 py-8 text-center text-[#10211c]" | |
| > | |
| <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#10211c]/45"> | |
| {flipped ? "Answer" : "Tap to flip"} | |
| </span> | |
| <span | |
| className="mt-3 text-[18px] font-semibold leading-7 sm:text-[20px]" | |
| style={{ fontFamily: display }} | |
| > | |
| {flipped ? ( | |
| <span className="inline-block rounded-[10px] bg-[#10211c] px-3 py-2 font-mono text-[16px] text-[#c6e04a]"> | |
| {card.back} | |
| </span> | |
| ) : ( | |
| card.front | |
| )} | |
| </span> | |
| </button> | |
| <div className="flex gap-2"> | |
| <button | |
| type="button" | |
| onClick={() => advanceCard(false)} | |
| className="min-h-11 flex-1 rounded-full border border-white/15 text-[14px] font-semibold text-[#f2f4f1]/80" | |
| > | |
| Again | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => advanceCard(true)} | |
| className="min-h-11 flex-1 rounded-full bg-[#c6e04a] text-[14px] font-semibold text-[#10211c]" | |
| > | |
| Know | |
| </button> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={markDone} | |
| className="inline-flex min-h-11 w-full items-center justify-center rounded-full border border-[#c6e04a]/40 px-5 text-[14px] font-semibold text-[#c6e04a]" | |
| > | |
| {done ? "Tonight’s chapter step marked done ✓" : "Mark tonight’s chapter step done"} | |
| </button> | |
| <p className="text-[11px] text-[#f2f4f1]/40">localStorage only · account progress untouched</p> | |
| </section> | |
| ); | |
| } | |