initialize project with basic structure and dependencies
This commit is contained in:
+100
-49
@@ -10,7 +10,7 @@ const { data: course, error, refresh } = await useAsyncData(
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function hasUnreadyTopics(): boolean {
|
||||
return course.value?.topics?.some((t: any) => !t.hasLesson) ?? false;
|
||||
return course.value?.topics?.some((t: any) => t.status === "generating") ?? false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -333,60 +333,73 @@ async function saveTitle() {
|
||||
<!-- topic list -->
|
||||
<div class="topic-list">
|
||||
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
|
||||
<template v-for="(topic, idx) in course.topics" :key="topic.id">
|
||||
<NuxtLink
|
||||
v-if="topic.hasLesson"
|
||||
:to="`/learn/${topic.id}`"
|
||||
class="topic-row topic-row--unlocked"
|
||||
:class="topic.progress?.lessonComplete ? 'topic-row--complete' : 'topic-row--available'"
|
||||
>
|
||||
<span class="topic-index">{{ String(idx + 1).padStart(2, "0") }}</span>
|
||||
<NuxtLink
|
||||
v-for="(topic, idx) in course.topics"
|
||||
:key="topic.id"
|
||||
:to="`/learn/${topic.id}`"
|
||||
class="topic-row topic-row--unlocked"
|
||||
:class="{
|
||||
'topic-row--complete': topic.progress?.lessonComplete,
|
||||
'topic-row--available': !topic.progress?.lessonComplete && topic.status === 'ready',
|
||||
'topic-row--pending': topic.status === 'pending' || topic.status === 'generating',
|
||||
}"
|
||||
>
|
||||
<span class="topic-index" :style="topic.status === 'pending' ? 'opacity:0.4' : ''">
|
||||
{{ String(idx + 1).padStart(2, "0") }}
|
||||
</span>
|
||||
|
||||
<div class="topic-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<span class="topic-name">{{ topic.title }}</span>
|
||||
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
|
||||
<div class="topic-info">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<span class="topic-name" :style="topic.status === 'pending' ? 'opacity:0.55' : ''">{{ topic.title }}</span>
|
||||
<span v-if="topic.progress?.lessonComplete" class="done-badge">done</span>
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
|
||||
class="branch-dot"
|
||||
title="You needed a little extra help here — that's completely normal."
|
||||
/>
|
||||
<span v-if="topic.status === 'pending'" class="topic-status-label topic-status-label--pending">Not yet generated</span>
|
||||
<span v-else-if="topic.status === 'generating'" class="topic-status-label topic-status-label--generating">
|
||||
<span class="generating-dot" />Generating...
|
||||
</span>
|
||||
<span v-else-if="topic.status === 'error'" class="topic-status-label topic-status-label--error">Error</span>
|
||||
</div>
|
||||
|
||||
<div class="topic-meta">
|
||||
<div class="diff-dots">
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
|
||||
class="branch-dot"
|
||||
title="You needed a little extra help here — that's completely normal."
|
||||
v-for="d in 5"
|
||||
:key="d"
|
||||
class="diff-dot"
|
||||
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="topic-meta">
|
||||
<div class="diff-dots">
|
||||
<span
|
||||
v-for="d in 5"
|
||||
:key="d"
|
||||
class="diff-dot"
|
||||
:class="d <= topic.difficulty ? 'diff-dot--filled' : 'diff-dot--empty'"
|
||||
/>
|
||||
</div>
|
||||
<span class="diff-label">
|
||||
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
|
||||
</span>
|
||||
<span v-if="topic.progress?.quizScore != null" class="diff-label">
|
||||
· quiz {{ topic.progress.quizScore }}/4
|
||||
</span>
|
||||
</div>
|
||||
<span class="diff-label">
|
||||
{{ ['', 'intro', 'basic', 'intermediate', 'advanced', 'expert'][topic.difficulty] }}
|
||||
</span>
|
||||
<span v-if="topic.progress?.quizScore != null" class="diff-label">
|
||||
· quiz {{ topic.progress.quizScore }}/4
|
||||
</span>
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.lessonCost != null"
|
||||
class="lesson-cost-badge"
|
||||
title="AI generation cost for this lesson"
|
||||
>
|
||||
~${{ topic.lessonCost.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<svg class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-else class="topic-row topic-row--generating">
|
||||
<span class="topic-index" style="opacity: 0.4;">{{ String(idx + 1).padStart(2, "0") }}</span>
|
||||
<div class="topic-info">
|
||||
<span class="topic-name" style="opacity: 0.5;">{{ topic.title }}</span>
|
||||
</div>
|
||||
<span class="generating-label">
|
||||
<span class="generating-dot" />
|
||||
Generating...
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<svg v-if="topic.status !== 'error'" class="topic-arrow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<button
|
||||
v-else
|
||||
class="topic-retry-btn"
|
||||
@click.prevent="$fetch(`/api/topics/${topic.id}/generate`, { method: 'POST' }).then(() => refresh())"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -827,4 +840,42 @@ async function saveTitle() {
|
||||
cursor: pointer; transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.audit-show-btn:hover { border-color: var(--border-2); color: var(--text); }
|
||||
|
||||
/* topic status labels */
|
||||
.topic-status-label {
|
||||
font-size: 10px; letter-spacing: 0.08em;
|
||||
padding: 1px 7px; border-radius: 20px; font-weight: 500;
|
||||
}
|
||||
.topic-status-label--pending {
|
||||
color: var(--text-3); background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.topic-status-label--generating {
|
||||
color: var(--accent); background: var(--surface-2);
|
||||
border: 1px solid var(--accent-dim);
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.topic-status-label--error {
|
||||
color: oklch(50% 0.18 25); background: oklch(95% 0.03 15);
|
||||
border: 1px solid oklch(80% 0.06 15);
|
||||
}
|
||||
|
||||
/* topic row states */
|
||||
.topic-row--pending { border-left-color: var(--border); }
|
||||
|
||||
/* lesson cost badge */
|
||||
.lesson-cost-badge {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 10px; color: var(--text-3); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* retry button on error topics */
|
||||
.topic-retry-btn {
|
||||
font-size: 11px; letter-spacing: 0.06em;
|
||||
background: none; border: 1px solid oklch(80% 0.06 15);
|
||||
color: oklch(50% 0.18 25); border-radius: 20px;
|
||||
padding: 3px 10px; cursor: pointer; flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.topic-retry-btn:hover { background: oklch(95% 0.03 15); }
|
||||
</style>
|
||||
|
||||
+189
-4
@@ -77,6 +77,40 @@ async function retry(courseId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteModal = ref(false);
|
||||
const deleteLicenseKey = ref("");
|
||||
const deleteLicenseError = ref<string | null>(null);
|
||||
const pendingDeleteId = ref<string | null>(null);
|
||||
const deleting = ref(false);
|
||||
|
||||
function requestDelete(courseId: string) {
|
||||
pendingDeleteId.value = courseId;
|
||||
deleteLicenseKey.value = "";
|
||||
deleteLicenseError.value = null;
|
||||
showDeleteModal.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteLicenseKey.value.trim()) {
|
||||
deleteLicenseError.value = "Enter a license key.";
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch(`/api/courses/${pendingDeleteId.value}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-license-key": deleteLicenseKey.value.trim() },
|
||||
});
|
||||
showDeleteModal.value = false;
|
||||
pendingDeleteId.value = null;
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
deleteLicenseError.value = err?.data?.message ?? "Delete failed";
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function progressPct(course: CourseSummary): number {
|
||||
if (!course.topicCount) return 0;
|
||||
return Math.round((course.completedCount / course.topicCount) * 100);
|
||||
@@ -150,10 +184,17 @@ function subjectColor(subject: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="status-badge" :class="`status-badge--${course.status}`">
|
||||
<span v-if="course.status === 'processing'" class="badge-pulse" />
|
||||
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
|
||||
</span>
|
||||
<div class="card-top-right">
|
||||
<span class="status-badge" :class="`status-badge--${course.status}`">
|
||||
<span v-if="course.status === 'processing'" class="badge-pulse" />
|
||||
{{ course.status === 'ready' ? 'Ready' : course.status === 'error' ? 'Error' : 'Processing' }}
|
||||
</span>
|
||||
<button class="delete-btn" title="Delete course" @click.stop.prevent="requestDelete(course.id)">
|
||||
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5l1 12h6l1-12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bottom -->
|
||||
@@ -212,6 +253,36 @@ function subjectColor(subject: string): string {
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="showDeleteModal" class="modal-backdrop" @click.self="showDeleteModal = false">
|
||||
<div class="modal-box">
|
||||
<p class="modal-title">Delete course?</p>
|
||||
<p class="modal-sub">This is permanent. Enter your license key to confirm.</p>
|
||||
|
||||
<input
|
||||
v-model="deleteLicenseKey"
|
||||
class="modal-input"
|
||||
type="text"
|
||||
placeholder="paste key here…"
|
||||
autofocus
|
||||
@keydown.enter="confirmDelete"
|
||||
@keydown.esc="showDeleteModal = false"
|
||||
/>
|
||||
|
||||
<p v-if="deleteLicenseError" class="modal-error">{{ deleteLicenseError }}</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" @click="showDeleteModal = false">Cancel</button>
|
||||
<button class="modal-confirm modal-confirm--danger" :disabled="deleting" @click="confirmDelete">
|
||||
{{ deleting ? 'Deleting…' : 'Delete' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -570,4 +641,118 @@ function subjectColor(subject: string): string {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-top-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-3);
|
||||
padding: 2px;
|
||||
opacity: 0;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
line-height: 0;
|
||||
}
|
||||
.course-card:hover .delete-btn { opacity: 1; }
|
||||
.delete-btn:hover { color: oklch(42% 0.12 15); }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-surface);
|
||||
padding: 28px 28px 24px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.modal-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.modal-input:focus { border-color: var(--accent); }
|
||||
|
||||
.modal-error {
|
||||
font-size: 12px;
|
||||
color: oklch(42% 0.12 15);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
font-size: 14px;
|
||||
color: var(--text-3);
|
||||
background: none;
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-btn);
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.modal-cancel:hover { color: var(--text); }
|
||||
|
||||
.modal-confirm {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--r-btn);
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.modal-confirm:hover:not(:disabled) { opacity: 0.88; }
|
||||
.modal-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.modal-confirm--danger {
|
||||
background: oklch(48% 0.14 15);
|
||||
}
|
||||
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -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