initialize project with basic structure and dependencies
This commit is contained in:
@@ -5,10 +5,124 @@ import confetti from "canvas-confetti";
|
||||
const route = useRoute();
|
||||
const topicId = route.params.id as string;
|
||||
|
||||
const { data: lesson, pending: lessonPending, error: lessonError } = useAsyncData(
|
||||
`lesson-${topicId}`,
|
||||
() => $fetch<any>(`/api/topics/${topicId}/lesson`)
|
||||
);
|
||||
// ── JIT generation state ───────────────────────────────────────────────────
|
||||
|
||||
type GenState = "loading" | "ready" | "error";
|
||||
|
||||
const lesson = ref<any>(null);
|
||||
const lessonPending = ref(false);
|
||||
const lessonError = ref<any>(null);
|
||||
const genState = ref<GenState | null>(null);
|
||||
|
||||
const GEN_TIPS = [
|
||||
"Reading through your past papers...",
|
||||
"Crafting analogies just for this topic...",
|
||||
"Writing your lesson from scratch...",
|
||||
"Recording your audio narration...",
|
||||
"Almost there...",
|
||||
];
|
||||
const GEN_STAGES = [
|
||||
"Understanding the topic",
|
||||
"Writing your lesson",
|
||||
"Recording narration",
|
||||
"Finishing up",
|
||||
];
|
||||
|
||||
const genTipIndex = ref(0);
|
||||
const genTipVisible = ref(true);
|
||||
const genStageIndex = ref(0);
|
||||
|
||||
let genTipTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let genStageTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startGenAnimations() {
|
||||
genTipTimer = setInterval(() => {
|
||||
genTipVisible.value = false;
|
||||
setTimeout(() => {
|
||||
genTipIndex.value = (genTipIndex.value + 1) % GEN_TIPS.length;
|
||||
genTipVisible.value = true;
|
||||
}, 300);
|
||||
}, 3000);
|
||||
|
||||
genStageTimer = setInterval(() => {
|
||||
genStageIndex.value = (genStageIndex.value + 1) % GEN_STAGES.length;
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
function stopGenAnimations() {
|
||||
if (genTipTimer) { clearInterval(genTipTimer); genTipTimer = null; }
|
||||
if (genStageTimer) { clearInterval(genStageTimer); genStageTimer = null; }
|
||||
}
|
||||
|
||||
async function loadLesson() {
|
||||
lessonPending.value = true;
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
lesson.value = data;
|
||||
genState.value = "ready";
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 404 || err?.status === 404) {
|
||||
// lesson not generated yet — trigger JIT generation
|
||||
await triggerGeneration();
|
||||
} else {
|
||||
lessonError.value = err;
|
||||
}
|
||||
} finally {
|
||||
lessonPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerGeneration() {
|
||||
genState.value = "loading";
|
||||
startGenAnimations();
|
||||
try {
|
||||
const result = await $fetch<{ status: string }>(`/api/topics/${topicId}/generate`, { method: "POST" });
|
||||
stopGenAnimations();
|
||||
if (result.status === "ready") {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
lesson.value = data;
|
||||
genState.value = "ready";
|
||||
} else {
|
||||
genState.value = "error";
|
||||
}
|
||||
} catch {
|
||||
stopGenAnimations();
|
||||
genState.value = "error";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadLesson);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopGenAnimations();
|
||||
});
|
||||
|
||||
// ── branch loading overlay ─────────────────────────────────────────────────
|
||||
|
||||
const branchOverlayVisible = ref(false);
|
||||
const branchOverlayError = ref(false);
|
||||
|
||||
let branchPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startBranchPoll() {
|
||||
branchPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await $fetch<any>(`/api/topics/${topicId}/lesson`);
|
||||
if (data.branchStatus === "ready") {
|
||||
lesson.value = data;
|
||||
stopBranchPoll();
|
||||
branchOverlayVisible.value = false;
|
||||
} else if (data.branchStatus === "error") {
|
||||
stopBranchPoll();
|
||||
branchOverlayError.value = true;
|
||||
}
|
||||
} catch { /* ignore poll errors */ }
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopBranchPoll() {
|
||||
if (branchPollTimer) { clearInterval(branchPollTimer); branchPollTimer = null; }
|
||||
}
|
||||
|
||||
// ── lesson state machine ───────────────────────────────────────────────────
|
||||
|
||||
@@ -203,11 +317,36 @@ const LABELS = ["A", "B", "C", "D"];
|
||||
|
||||
// ── branch tracking ────────────────────────────────────────────────────────
|
||||
|
||||
const pendingBranchStep = ref<number | null>(null);
|
||||
const pendingBranchOption = ref<string>("");
|
||||
|
||||
// when branch overlay dismisses after poll completes, fire the deferred branch
|
||||
watch(branchOverlayVisible, (visible) => {
|
||||
if (!visible && pendingBranchStep.value !== null && !branchOverlayError.value) {
|
||||
const step = pendingBranchStep.value;
|
||||
const opt = pendingBranchOption.value;
|
||||
pendingBranchStep.value = null;
|
||||
pendingBranchOption.value = "";
|
||||
enterBranchMode(step, opt);
|
||||
}
|
||||
});
|
||||
|
||||
const branchesEntered = ref(0);
|
||||
let transitionTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let confirmAdvanceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function enterBranchMode(stepIndex: number, wrongOption: string) {
|
||||
// if branches arent ready yet, show overlay and wait
|
||||
if (lesson.value?.branchStatus !== "ready") {
|
||||
branchOverlayVisible.value = true;
|
||||
branchOverlayError.value = false;
|
||||
startBranchPoll();
|
||||
// store where we want to go when branches become available
|
||||
pendingBranchStep.value = stepIndex;
|
||||
pendingBranchOption.value = wrongOption;
|
||||
return;
|
||||
}
|
||||
|
||||
branchesEntered.value++;
|
||||
lessonState.mode = "transition";
|
||||
lessonState.stepIndex = stepIndex;
|
||||
@@ -619,13 +758,72 @@ function continueAfterQuestion() {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAllAudio();
|
||||
stopBranchPoll();
|
||||
if (transitionTimeout) clearTimeout(transitionTimeout);
|
||||
if (confirmAdvanceTimeout) clearTimeout(confirmAdvanceTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── JIT GENERATION LOADING SCREEN ─────────────────────────────────────── -->
|
||||
<Transition name="fullscreen-fade">
|
||||
<div v-if="genState === 'loading'" class="gen-loading-screen">
|
||||
<div class="gen-inner">
|
||||
<div class="gen-logo">Revisi<span>.one</span></div>
|
||||
|
||||
<div class="gen-pulse-ring">
|
||||
<div class="gen-pulse-circle" />
|
||||
<div class="gen-pulse-dot">
|
||||
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="oklch(54% 0.140 44)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="tip">
|
||||
<p v-if="genTipVisible" :key="genTipIndex" class="gen-tip">{{ GEN_TIPS[genTipIndex] }}</p>
|
||||
</Transition>
|
||||
|
||||
<div class="gen-stepper">
|
||||
<div
|
||||
v-for="(stage, i) in GEN_STAGES"
|
||||
:key="i"
|
||||
class="gen-step"
|
||||
:class="i === genStageIndex ? 'gen-step--active' : i < genStageIndex ? 'gen-step--done' : 'gen-step--pending'"
|
||||
>
|
||||
<div class="gen-step-node">
|
||||
<div v-if="i < genStageIndex" class="gen-node-done">
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="i === genStageIndex" class="gen-node-active">
|
||||
<div class="gen-node-ring" />
|
||||
<div class="gen-node-dot" />
|
||||
</div>
|
||||
<div v-else class="gen-node-pending" />
|
||||
</div>
|
||||
<span class="gen-step-label">{{ stage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ── JIT GENERATION ERROR ────────────────────────────────────────────── -->
|
||||
<Transition name="fullscreen-fade">
|
||||
<div v-if="genState === 'error'" class="gen-loading-screen">
|
||||
<div class="gen-inner">
|
||||
<div class="gen-logo">Revisi<span>.one</span></div>
|
||||
<p class="gen-error-msg">We couldn't generate this lesson. Please try again.</p>
|
||||
<button class="gen-retry-btn" @click="triggerGeneration">Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
v-if="genState === 'ready' || genState === null"
|
||||
class="lesson-shell"
|
||||
:style="{ '--step-bg': stepBg, '--accent': accentColor }"
|
||||
>
|
||||
@@ -899,6 +1097,32 @@ onBeforeUnmount(() => {
|
||||
|
||||
</template>
|
||||
|
||||
<!-- ── BRANCH LOADING OVERLAY ────────────────────────────────────────── -->
|
||||
<Transition name="fullscreen-fade">
|
||||
<div v-if="branchOverlayVisible" class="branch-loading-overlay">
|
||||
<div class="branch-loading-inner">
|
||||
<template v-if="!branchOverlayError">
|
||||
<div class="branch-loading-pulse">
|
||||
<div class="branch-loading-ring" />
|
||||
<div class="branch-loading-icon">
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#92400E">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="branch-loading-text">Preparing personalised feedback…</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="branch-loading-error">We couldn't prepare feedback for this answer.</p>
|
||||
<button class="branch-loading-skip" @click="() => { branchOverlayVisible = false; pendingBranchStep = null; }">
|
||||
Continue
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1505,4 +1729,255 @@ onBeforeUnmount(() => {
|
||||
.step-body { font-size: 1rem; }
|
||||
.focus-label { display: none; }
|
||||
}
|
||||
|
||||
/* ── JIT generation loading screen ──────────────────────────────────────── */
|
||||
.gen-loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: #FAFAF8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gen-logo {
|
||||
font-family: "DM Serif Display", Georgia, serif;
|
||||
font-size: 1.5rem;
|
||||
color: oklch(35% 0.018 58);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.gen-logo span {
|
||||
color: oklch(54% 0.140 44);
|
||||
}
|
||||
|
||||
.gen-pulse-ring {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-pulse-circle {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid oklch(82% 0.055 76);
|
||||
animation: gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gen-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.15); opacity: 0.3; }
|
||||
}
|
||||
|
||||
.gen-pulse-dot {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: oklch(95% 0.030 76);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid oklch(85% 0.045 72);
|
||||
}
|
||||
|
||||
.gen-tip {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.9375rem;
|
||||
color: oklch(50% 0.016 65);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.gen-stepper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gen-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.gen-step-node {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-node-done {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: oklch(54% 0.140 44);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-node-active {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-node-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid oklch(54% 0.140 44);
|
||||
animation: gen-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gen-node-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: oklch(54% 0.140 44);
|
||||
}
|
||||
|
||||
.gen-node-pending {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: oklch(82% 0.022 73);
|
||||
}
|
||||
|
||||
.gen-step-label {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.gen-step--done .gen-step-label { color: oklch(54% 0.140 44); }
|
||||
.gen-step--active .gen-step-label { color: oklch(35% 0.018 58); font-weight: 600; }
|
||||
.gen-step--pending .gen-step-label { color: oklch(72% 0.016 65); }
|
||||
|
||||
.gen-error-msg {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.9375rem;
|
||||
color: oklch(45% 0.18 25);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gen-retry-btn {
|
||||
background: oklch(54% 0.140 44);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0.75rem 2rem;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.gen-retry-btn:hover { opacity: 0.85; }
|
||||
|
||||
.tip-enter-active { transition: opacity 0.3s ease; }
|
||||
.tip-leave-active { transition: opacity 0.2s ease; }
|
||||
.tip-enter-from, .tip-leave-to { opacity: 0; }
|
||||
|
||||
/* ── branch loading overlay ──────────────────────────────────────────────── */
|
||||
.branch-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(255, 251, 235, 0.88);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.branch-loading-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.branch-loading-pulse {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.branch-loading-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #F59E0B;
|
||||
animation: gen-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.branch-loading-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #FEF3C7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid #FCD34D;
|
||||
}
|
||||
|
||||
.branch-loading-text {
|
||||
font-family: "DM Serif Display", Georgia, serif;
|
||||
font-size: 1.0625rem;
|
||||
color: #92400E;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.branch-loading-error {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 0.9375rem;
|
||||
color: #92400E;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.branch-loading-skip {
|
||||
background: #F59E0B;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0.75rem 2rem;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.branch-loading-skip:hover { opacity: 0.85; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user