harden database interactions and improve error handling
This commit is contained in:
+142
-52
@@ -14,6 +14,9 @@ const lessonPending = ref(false);
|
||||
const lessonError = ref<any>(null);
|
||||
const genState = ref<GenState | null>(null);
|
||||
|
||||
const abortController = ref<AbortController | null>(null);
|
||||
const generationFired = ref(false);
|
||||
|
||||
const GEN_TIPS = [
|
||||
"Reading through your past papers...",
|
||||
"Crafting analogies just for this topic...",
|
||||
@@ -36,6 +39,7 @@ let genTipTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let genStageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startGenAnimations() {
|
||||
if (genTipTimer) return;
|
||||
genTipTimer = setInterval(() => {
|
||||
genTipVisible.value = false;
|
||||
setTimeout(() => {
|
||||
@@ -55,7 +59,12 @@ function stopGenAnimations() {
|
||||
}
|
||||
|
||||
async function loadLesson() {
|
||||
lessonPending.value = true;
|
||||
lesson.value = null;
|
||||
|
||||
const pendingTimer = setTimeout(() => { lessonPending.value = true; }, 100);
|
||||
|
||||
abortController.value = new AbortController();
|
||||
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
lesson.value = data;
|
||||
@@ -68,11 +77,15 @@ async function loadLesson() {
|
||||
lessonError.value = err;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(pendingTimer);
|
||||
lessonPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerGeneration() {
|
||||
if (generationFired.value) return;
|
||||
generationFired.value = true;
|
||||
|
||||
genState.value = "loading";
|
||||
startGenAnimations();
|
||||
try {
|
||||
@@ -95,6 +108,7 @@ onMounted(loadLesson);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopGenAnimations();
|
||||
abortController.value?.abort();
|
||||
});
|
||||
|
||||
// ── branch loading overlay ─────────────────────────────────────────────────
|
||||
@@ -103,8 +117,13 @@ const branchOverlayVisible = ref(false);
|
||||
const branchOverlayError = ref(false);
|
||||
|
||||
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const branchPollStart = ref(0);
|
||||
|
||||
function startBranchPoll() {
|
||||
if (branchPollTimer) return;
|
||||
|
||||
branchPollStart.value = Date.now();
|
||||
|
||||
branchPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
@@ -117,6 +136,11 @@ function startBranchPoll() {
|
||||
branchOverlayError.value = true;
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
|
||||
if (Date.now() - branchPollStart.value > 60_000) {
|
||||
stopBranchPoll();
|
||||
branchOverlayError.value = true;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -295,10 +319,12 @@ const showMainExplanation = computed(() => {
|
||||
|
||||
const shuffledOptions = ref<string[]>([]);
|
||||
|
||||
function fisherYates(arr: string[]): string[] {
|
||||
function seededShuffle(arr: string[], seed: number): string[] {
|
||||
const a = [...arr];
|
||||
let s = seed;
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
const j = s % (i + 1);
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
@@ -307,7 +333,8 @@ function fisherYates(arr: string[]): string[] {
|
||||
watch([() => stepKey.value, steps], () => {
|
||||
const s = displayStep.value;
|
||||
if (s?.type === "question" && s.options) {
|
||||
shuffledOptions.value = fisherYates(s.options);
|
||||
const seed = lessonState.stepIndex * 31 + (lessonState.branchOption ? lessonState.branchOption.charCodeAt(0) : 0);
|
||||
shuffledOptions.value = seededShuffle(s.options, seed);
|
||||
} else {
|
||||
shuffledOptions.value = [];
|
||||
}
|
||||
@@ -478,19 +505,24 @@ function advance() {
|
||||
}
|
||||
|
||||
const lessonScore = ref(0);
|
||||
watch(selectedAnswers, (opts, prev) => {
|
||||
watch(() => ({ ...selectedAnswers.value }), (opts, prev) => {
|
||||
const newKeys = Object.keys(opts).map(Number).filter(k => !(k in prev));
|
||||
for (const k of newKeys) {
|
||||
const s = steps.value[k];
|
||||
if (s?.type === "question" && opts[k] === s.answer) lessonScore.value++;
|
||||
}
|
||||
}, { deep: true });
|
||||
});
|
||||
|
||||
watch(() => [lessonState.mode, lessonState.stepIndex], ([mode, idx]) => {
|
||||
if (mode === "main" && steps.value[idx as number]?.type === "summary") fireCelebration();
|
||||
});
|
||||
|
||||
const celebrationFired = ref(false);
|
||||
|
||||
function fireCelebration() {
|
||||
if (celebrationFired.value) return;
|
||||
celebrationFired.value = true;
|
||||
|
||||
const count = 160;
|
||||
const defaults = { startVelocity: 35, spread: 80, ticks: 70, zIndex: 200 };
|
||||
const interval = setInterval(() => {
|
||||
@@ -502,13 +534,20 @@ function fireCelebration() {
|
||||
|
||||
const lessonDone = ref(false);
|
||||
const completing = ref(false);
|
||||
const completeError = ref(false);
|
||||
|
||||
async function completeLesson() {
|
||||
if (completing.value || lessonDone.value) return;
|
||||
completing.value = true;
|
||||
completeError.value = false;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||||
|
||||
try {
|
||||
await $fetch(`/api/topics/${topicId}/progress`, {
|
||||
method: "POST",
|
||||
signal: ctrl.signal,
|
||||
body: {
|
||||
lessonComplete: true,
|
||||
tookBranches: branchesEntered.value > 0,
|
||||
@@ -516,12 +555,38 @@ async function completeLesson() {
|
||||
},
|
||||
});
|
||||
lessonDone.value = true;
|
||||
} catch {
|
||||
completing.value = false;
|
||||
completeError.value = true;
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
completing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() { history.back(); }
|
||||
const router = useRouter();
|
||||
function goBack() {
|
||||
if (history.length <= 1) { router.push("/"); return; }
|
||||
history.back();
|
||||
}
|
||||
|
||||
function stepBack() {
|
||||
stopAllAudio();
|
||||
if (lessonState.mode === "branch" || lessonState.mode === "confirm" || lessonState.mode === "hardStop") {
|
||||
// exit branch back to main question
|
||||
lessonState.mode = "main";
|
||||
lessonState.branchOption = "";
|
||||
lessonState.branchStepIndex = 0;
|
||||
confirmSelectedAnswer.value = null;
|
||||
return;
|
||||
}
|
||||
if (lessonState.stepIndex > 0) {
|
||||
lessonState.stepIndex--;
|
||||
// clear the answer for this step so they can re-answer
|
||||
delete selectedAnswers.value[lessonState.stepIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// ── FOCUS MODE ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -529,6 +594,7 @@ const focusMode = ref(false);
|
||||
const currentChunk = ref<{ text: string; start: number; end: number } | null>(null);
|
||||
const karaokeAudioEl = ref<HTMLAudioElement | null>(null);
|
||||
const questionPlaying = ref(false);
|
||||
const narratingOptionIndex = ref<number | null>(null);;
|
||||
const focusEverActivated = ref(false);
|
||||
|
||||
if (import.meta.client) {
|
||||
@@ -550,6 +616,7 @@ const showKaraoke = computed(() => {
|
||||
return !!(ds?.audioPath && ds?.audioChunks && (ds.audioChunks as any[]).length > 0);
|
||||
});
|
||||
|
||||
// kept for compat — no longer drives a full overlay, just used to gate the continue button
|
||||
const showQuestionOverlay = computed(() =>
|
||||
focusMode.value &&
|
||||
(lessonState.mode === "main" || lessonState.mode === "confirm") &&
|
||||
@@ -656,13 +723,8 @@ async function playSequence(urls: string[]): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const questionVariants = ref<Record<number, number>>({});
|
||||
|
||||
function getVariant(stepIndex: number): number {
|
||||
if (!questionVariants.value[stepIndex]) {
|
||||
questionVariants.value[stepIndex] = Math.floor(Math.random() * 4) + 1;
|
||||
}
|
||||
return questionVariants.value[stepIndex];
|
||||
return (Math.abs(stepIndex) % 4) + 1;
|
||||
}
|
||||
|
||||
async function playQuestionLike(
|
||||
@@ -672,26 +734,31 @@ async function playQuestionLike(
|
||||
) {
|
||||
stopQueue();
|
||||
questionPlaying.value = true;
|
||||
narratingOptionIndex.value = null;
|
||||
|
||||
const variant = getVariant(variantKey);
|
||||
const playlist: string[] = [];
|
||||
|
||||
if (questionStep.questionAudioPath) playlist.push(questionStep.questionAudioPath);
|
||||
|
||||
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
|
||||
const label = LABELS[oi];
|
||||
playlist.push(`/audio/labels/${label}_${variant}.mp3`);
|
||||
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
|
||||
const optAudio = questionStep.optionAudioPaths?.[origIdx];
|
||||
if (optAudio) playlist.push(optAudio);
|
||||
}
|
||||
|
||||
try {
|
||||
await playSequence(playlist);
|
||||
// play question stem first
|
||||
if (questionStep.questionAudioPath) {
|
||||
await playSequence([questionStep.questionAudioPath]);
|
||||
}
|
||||
|
||||
// play each option in order, highlighting it while it plays
|
||||
for (let oi = 0; oi < shuffledOptions.value.length; oi++) {
|
||||
narratingOptionIndex.value = oi;
|
||||
const label = LABELS[oi];
|
||||
const origIdx = (questionStep.options ?? []).indexOf(shuffledOptions.value[oi]);
|
||||
const optAudio = questionStep.optionAudioPaths?.[origIdx];
|
||||
const clips: string[] = [`/audio/labels/${label}_${variant}.mp3`];
|
||||
if (optAudio) clips.push(optAudio);
|
||||
await playSequence(clips);
|
||||
}
|
||||
} catch {
|
||||
// aborted
|
||||
}
|
||||
|
||||
narratingOptionIndex.value = null;
|
||||
questionPlaying.value = false;
|
||||
}
|
||||
|
||||
@@ -704,10 +771,9 @@ function startStepAudio() {
|
||||
|
||||
if (s.type === "concept" || s.type === "example" || s.type === "summary") {
|
||||
if (!s.audioPath || !s.audioChunks || (s.audioChunks as any[]).length === 0) {
|
||||
// no usable audio for this step — skip it automatically so focus mode stays alive
|
||||
// no usable audio — advance state only, the watcher fires startStepAudio for the new step
|
||||
if (!isLastMainStep.value) {
|
||||
lessonState.stepIndex++;
|
||||
nextTick(() => startStepAudio());
|
||||
} else {
|
||||
completeLesson();
|
||||
}
|
||||
@@ -720,7 +786,7 @@ function startStepAudio() {
|
||||
startKaraoke(audio, s.audioChunks, () => {
|
||||
if (!isLastMainStep.value) {
|
||||
lessonState.stepIndex++;
|
||||
nextTick(() => startStepAudio());
|
||||
// watcher handles startStepAudio for the new step
|
||||
} else {
|
||||
completeLesson();
|
||||
}
|
||||
@@ -862,7 +928,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- ── TOP BAR ─────────────────────────────────────────────────────────── -->
|
||||
<header class="topbar">
|
||||
<button class="topbar-back" @click="goBack" aria-label="Back">
|
||||
<button class="topbar-back" @click="stepBack" aria-label="Previous step" :disabled="lessonState.stepIndex === 0 && lessonState.mode === 'main'">
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
@@ -892,29 +958,33 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
<span class="focus-label">Focus Mode</span>
|
||||
</button>
|
||||
|
||||
<button class="topbar-exit" @click="goBack" aria-label="Exit lesson" title="Back to course">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ── KARAOKE OVERLAY ────────────────────────────────────────────────── -->
|
||||
<Transition name="karaoke-fade">
|
||||
<div v-if="showKaraoke" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
||||
<p class="karaoke-text">{{ currentChunk?.text ?? '…' }}</p>
|
||||
<button class="karaoke-skip" @click="skipStep" title="Skip">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ── QUESTION AUDIO OVERLAY ─────────────────────────────────────────── -->
|
||||
<Transition name="karaoke-fade">
|
||||
<div v-if="showQuestionOverlay" class="karaoke-overlay" :style="{ backgroundColor: stepBg }">
|
||||
<p class="karaoke-text karaoke-text--question">Listen carefully…</p>
|
||||
<button class="karaoke-skip" @click="stopQueue" title="Skip to question">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<template v-if="currentChunk">
|
||||
<p class="karaoke-text">{{ currentChunk.text }}</p>
|
||||
<button class="karaoke-skip" @click="skipStep" title="Skip">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="karaoke-start" @click="startStepAudio">
|
||||
<svg width="22" height="22" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -956,7 +1026,7 @@ onBeforeUnmount(() => {
|
||||
<!-- ── MAIN ───────────────────────────────────────────────────────────── -->
|
||||
<main
|
||||
class="lesson-main"
|
||||
v-show="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
|
||||
v-show="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'"
|
||||
>
|
||||
|
||||
<div v-if="lessonPending" class="state-center">
|
||||
@@ -1002,7 +1072,8 @@ onBeforeUnmount(() => {
|
||||
:key="opt"
|
||||
class="option"
|
||||
:class="{
|
||||
'option--hover-ready': !hasAnswered,
|
||||
'option--hover-ready': !hasAnswered && narratingOptionIndex === null,
|
||||
'option--narrating': !hasAnswered && narratingOptionIndex === i,
|
||||
'option--correct': hasAnswered && opt === displayStep.answer,
|
||||
'option--wrong': hasAnswered && selectedAnswer === opt && opt !== displayStep.answer,
|
||||
'option--dim': hasAnswered && selectedAnswer !== opt && opt !== displayStep.answer,
|
||||
@@ -1077,13 +1148,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!lessonDone"
|
||||
v-if="!lessonDone && !completeError"
|
||||
class="summary-cta"
|
||||
:disabled="completing"
|
||||
@click="completeLesson"
|
||||
>
|
||||
{{ completing ? 'Saving…' : 'Complete Lesson ✓' }}
|
||||
</button>
|
||||
|
||||
<button v-if="completeError" class="summary-cta" @click="completeLesson">Retry ✓</button>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
@@ -1092,7 +1165,7 @@ onBeforeUnmount(() => {
|
||||
</main>
|
||||
|
||||
<!-- ── CONTINUE FOOTER ───────────────────────────────────────────────── -->
|
||||
<template v-if="!showKaraoke && !showQuestionOverlay && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
|
||||
<template v-if="!showKaraoke && lessonState.mode !== 'transition' && lessonState.mode !== 'hardStop'">
|
||||
|
||||
<!-- non-question, non-summary steps -->
|
||||
<div
|
||||
@@ -1201,7 +1274,23 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.topbar-back:hover { opacity: 1; }
|
||||
.topbar-back:hover:not(:disabled) { opacity: 1; }
|
||||
.topbar-back:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
.topbar-exit {
|
||||
color: oklch(20% 0.020 55);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.topbar-exit:hover { opacity: 0.85; }
|
||||
|
||||
.progress-segments {
|
||||
flex: 1;
|
||||
@@ -1485,6 +1574,7 @@ onBeforeUnmount(() => {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.option--hover-ready:hover { border-color: #6366F1; background: #F5F3FF; }
|
||||
.option--narrating { border-color: #6366F1; background: #EEF2FF; box-shadow: 0 0 0 3px rgba(99,102,241,0.15); }
|
||||
.option--correct { border-color: #10B981; background: #D1FAE5; animation: spring-correct 0.3s ease-out; }
|
||||
.option--wrong { border-color: #EF4444; background: #FEE2E2; }
|
||||
.option--dim { opacity: 0.38; }
|
||||
|
||||
Reference in New Issue
Block a user