initialize project with basic structure and dependencies

This commit is contained in:
ImBenji
2026-04-27 23:59:15 +01:00
parent 83f2837ce6
commit f6f45500f8
13 changed files with 1443 additions and 579 deletions
+479 -4
View File
@@ -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>