881 lines
30 KiB
Vue
881 lines
30 KiB
Vue
<script setup lang="ts">
|
|
const route = useRoute();
|
|
const courseId = route.params.id as string;
|
|
|
|
const { data: course, error, refresh } = await useAsyncData(
|
|
`course-${courseId}`,
|
|
() => $fetch<any>(`/api/courses/${courseId}`)
|
|
);
|
|
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function hasUnreadyTopics(): boolean {
|
|
return course.value?.topics?.some((t: any) => t.status === "generating") ?? false;
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (course.value?.status === "processing" || hasUnreadyTopics()) startPolling();
|
|
});
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
pollTimer = setInterval(async () => {
|
|
await refresh();
|
|
if (course.value?.status !== "processing" && !hasUnreadyTopics()) stopPolling();
|
|
}, 3000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
}
|
|
|
|
onUnmounted(stopPolling);
|
|
|
|
type StageKey = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising";
|
|
|
|
const STEPS: { key: StageKey; title: string; subtitle: string }[] = [
|
|
{ key: "parsing_pdfs", title: "Reading your documents", subtitle: "Extracting content from uploaded PDFs" },
|
|
{ key: "analysing_sources", title: "Identifying key topics", subtitle: "Scanning past papers & lab worksheets" },
|
|
{ key: "building_curriculum", title: "Building your curriculum", subtitle: "Ordering topics from fundamentals up" },
|
|
{ key: "finalising", title: "Putting it all together", subtitle: "Saving your personalised course" },
|
|
];
|
|
|
|
const STAGE_ORDER: Record<string, number> = {
|
|
parsing_pdfs: 0,
|
|
analysing_sources: 1,
|
|
building_curriculum: 2,
|
|
finalising: 3,
|
|
ready: 4,
|
|
error: 4,
|
|
};
|
|
|
|
function stageIndex(stage: string | null | undefined): number {
|
|
return STAGE_ORDER[stage ?? "parsing_pdfs"] ?? 0;
|
|
}
|
|
|
|
function stepState(i: number, s: string | null | undefined): "done" | "active" | "pending" {
|
|
const cur = stageIndex(s);
|
|
if (i < cur) return "done";
|
|
if (i === cur) return "active";
|
|
return "pending";
|
|
}
|
|
|
|
const TIPS = [
|
|
"Topics from past papers are weighted more heavily",
|
|
"We build from the ground up — starting with the fundamentals",
|
|
"Lab worksheets inform the practical skills you'll be tested on",
|
|
"Your course is tailored to your exact exam style",
|
|
"The more past papers you upload, the better the course",
|
|
];
|
|
const tipIndex = ref(0);
|
|
const tipVisible = ref(true);
|
|
let tipTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
onMounted(() => {
|
|
tipTimer = setInterval(() => {
|
|
tipVisible.value = false;
|
|
setTimeout(() => { tipIndex.value = (tipIndex.value + 1) % TIPS.length; tipVisible.value = true; }, 350);
|
|
}, 4000);
|
|
});
|
|
onUnmounted(() => { if (tipTimer) clearInterval(tipTimer); });
|
|
|
|
|
|
function completedCount(): number {
|
|
return course.value?.topics?.filter((t: any) => t.progress?.lessonComplete).length ?? 0;
|
|
}
|
|
|
|
const auditReport = computed(() => {
|
|
if (!course.value?.auditReport) return null;
|
|
try { return JSON.parse(course.value.auditReport); } catch { return null; }
|
|
});
|
|
|
|
const auditHidden = ref(false);
|
|
const lessonQualityExpanded = ref(false);
|
|
|
|
if (import.meta.client) {
|
|
auditHidden.value = localStorage.getItem("revisione-audit-hidden") === "true";
|
|
}
|
|
|
|
function toggleAudit() {
|
|
auditHidden.value = !auditHidden.value;
|
|
if (import.meta.client) localStorage.setItem("revisione-audit-hidden", String(auditHidden.value));
|
|
}
|
|
|
|
function scoreColor(score: number): string {
|
|
if (score >= 80) return "var(--green)";
|
|
if (score >= 50) return "oklch(65% 0.14 65)";
|
|
return "oklch(52% 0.14 15)";
|
|
}
|
|
|
|
const editingTitle = ref(false);
|
|
const titleDraft = ref("");
|
|
|
|
function startEditTitle() {
|
|
titleDraft.value = course.value?.title ?? "";
|
|
editingTitle.value = true;
|
|
nextTick(() => (document.querySelector(".title-input") as HTMLInputElement)?.focus());
|
|
}
|
|
|
|
async function saveTitle() {
|
|
editingTitle.value = false;
|
|
const val = titleDraft.value.trim();
|
|
if (!val || val === course.value?.title) return;
|
|
await $fetch(`/api/courses/${courseId}`, {
|
|
method: "PATCH",
|
|
body: { title: val },
|
|
});
|
|
if (course.value) course.value.title = val;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="course-page">
|
|
|
|
<!-- header -->
|
|
<header class="course-header">
|
|
<div class="header-inner">
|
|
<NuxtLink to="/" class="back-link">
|
|
<svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M15 19l-7-7 7-7"/>
|
|
</svg>
|
|
Home
|
|
</NuxtLink>
|
|
|
|
<span class="header-logo">Revisi<span style="color: var(--accent);">.one</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="page-body">
|
|
|
|
<!-- dev audit panel -->
|
|
<div v-if="auditReport && !auditHidden" class="audit-panel">
|
|
<div class="audit-header">
|
|
<span class="audit-badge">DEV AUDIT</span>
|
|
<div class="audit-score" :style="{ color: scoreColor(auditReport.overallScore) }">
|
|
{{ auditReport.overallScore }}<span class="audit-score-denom"> / 100</span>
|
|
</div>
|
|
<button class="audit-hide-btn" @click="toggleAudit">Hide audit</button>
|
|
</div>
|
|
|
|
<div class="audit-standard-banner" :class="auditReport.passesStandard ? 'audit-standard-banner--pass' : 'audit-standard-banner--fail'">
|
|
{{ auditReport.passesStandard ? 'EXAM READY ✓' : 'NOT EXAM READY ✗' }}
|
|
</div>
|
|
|
|
<p class="audit-readiness">{{ auditReport.examReadiness }}</p>
|
|
|
|
<div class="audit-coverage">
|
|
Coverage: <strong>{{ auditReport.coverageAnalysis?.coveredTopics }}</strong> of
|
|
<strong>{{ auditReport.coverageAnalysis?.totalExaminedTopics }}</strong> examined topics
|
|
({{ auditReport.coverageAnalysis?.coveragePercent }}%)
|
|
</div>
|
|
|
|
<div v-if="auditReport.unansweredPaperQuestions?.length" class="audit-section">
|
|
<p class="audit-section-title">Unanswered Past Paper Questions</p>
|
|
<table class="audit-table">
|
|
<thead><tr><th>Source</th><th>Question</th><th>What's missing</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="(q, i) in auditReport.unansweredPaperQuestions" :key="i">
|
|
<td class="unanswered-source">{{ q.source }}</td>
|
|
<td>{{ q.question }}</td>
|
|
<td class="unanswered-gap">{{ q.gap }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="auditReport.gaps?.length" class="audit-section">
|
|
<p class="audit-section-title">Gaps</p>
|
|
<table class="audit-table">
|
|
<thead><tr><th>Topic</th><th>Severity</th><th>Appears in</th><th>Course coverage</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="(gap, i) in auditReport.gaps" :key="i">
|
|
<td>{{ gap.topic }}</td>
|
|
<td><span class="severity-badge" :class="`severity-badge--${gap.severity}`">{{ gap.severity }}</span></td>
|
|
<td>{{ gap.appearsInSources }}</td>
|
|
<td>{{ gap.courseCoverage }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="auditReport.recommendations?.length" class="audit-section">
|
|
<p class="audit-section-title">Recommendations</p>
|
|
<ol class="audit-recs">
|
|
<li v-for="(rec, i) in auditReport.recommendations" :key="i">{{ rec }}</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div v-if="auditReport.lessonQuality?.length" class="audit-section">
|
|
<button class="audit-collapse-btn" @click="lessonQualityExpanded = !lessonQualityExpanded">
|
|
Lesson Quality
|
|
<span class="collapse-arrow" :class="{ 'collapse-arrow--open': lessonQualityExpanded }">▸</span>
|
|
</button>
|
|
<div v-if="lessonQualityExpanded" class="lesson-quality-list">
|
|
<div v-for="(lq, i) in auditReport.lessonQuality" :key="i" class="lq-row">
|
|
<div class="lq-title">{{ lq.topicTitle }}</div>
|
|
<div class="lq-score" :style="{ color: scoreColor(lq.score) }">{{ lq.score }}/100</div>
|
|
<div class="lq-notes">{{ lq.notes }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="auditReport && auditHidden" class="audit-show-row">
|
|
<button class="audit-show-btn" @click="toggleAudit">Show audit report</button>
|
|
</div>
|
|
|
|
<!-- error -->
|
|
<div v-if="error || course?.status === 'error'" class="error-view">
|
|
<div class="error-card">
|
|
<p class="error-label">Error</p>
|
|
<p class="error-sub">We couldn't generate your course. Please try again.</p>
|
|
<NuxtLink to="/" class="error-back">← Start over</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-else-if="course">
|
|
|
|
<!-- processing stepper (shown while generating) -->
|
|
<div v-if="course.status === 'processing'" class="processing-view">
|
|
<div class="processing-header">
|
|
<p class="processing-label">Generating</p>
|
|
<h2 class="processing-title">{{ course.title }}</h2>
|
|
</div>
|
|
|
|
<div class="stepper">
|
|
<div
|
|
v-for="(step, i) in STEPS"
|
|
:key="step.key"
|
|
class="stepper-item"
|
|
:class="`stepper-item--${stepState(i, course.stage)}`"
|
|
>
|
|
<div class="stepper-node">
|
|
<div v-if="stepState(i, course.stage) === 'done'" class="node node--done">
|
|
<svg width="12" height="12" 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="stepState(i, course.stage) === 'active'" class="node node--active">
|
|
<div class="node-pulse" />
|
|
<div class="node-dot" />
|
|
</div>
|
|
<div v-else class="node node--pending">
|
|
<div class="node-dot-sm" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="i < STEPS.length - 1" class="stepper-line"
|
|
:class="stepState(i, course.stage) === 'done' ? 'stepper-line--done' : 'stepper-line--pending'" />
|
|
|
|
<div class="stepper-text">
|
|
<p class="stepper-title">{{ step.title }}</p>
|
|
<p class="stepper-sub">{{ step.subtitle }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tip-area">
|
|
<Transition name="tip">
|
|
<p v-if="tipVisible" :key="tipIndex" class="tip-text">"{{ TIPS[tipIndex] }}"</p>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- course view (always shown once we have topics, or when ready) -->
|
|
<div v-if="course.status === 'ready' || course.topics?.length" class="course-view">
|
|
|
|
<div class="course-meta">
|
|
<div class="course-subject">{{ course.subject }}</div>
|
|
|
|
<div class="title-edit-wrap" @click="!editingTitle && startEditTitle()">
|
|
<input
|
|
v-if="editingTitle"
|
|
v-model="titleDraft"
|
|
class="title-input course-title"
|
|
@blur="saveTitle"
|
|
@keydown.enter="saveTitle"
|
|
@keydown.escape="editingTitle = false"
|
|
/>
|
|
<h1 v-else class="course-title">
|
|
{{ course.title }}
|
|
<svg class="title-pencil" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
|
</svg>
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- org chip -->
|
|
<div v-if="course.organisation" class="org-chips" style="margin-bottom: 16px;">
|
|
<span class="org-chip">{{ course.organisation }}</span>
|
|
</div>
|
|
|
|
<div class="course-stats">
|
|
<span class="stat">
|
|
<span class="stat-num">{{ course.topics?.length ?? 0 }}</span>
|
|
topics
|
|
</span>
|
|
<span class="stat-divider">·</span>
|
|
<span class="stat">
|
|
<span class="stat-num stat-num--green">{{ completedCount() }}</span>
|
|
completed
|
|
</span>
|
|
</div>
|
|
|
|
<div class="progress-track" v-if="course.topics?.length">
|
|
<div
|
|
class="progress-fill"
|
|
:style="{ width: `${(completedCount() / course.topics.length) * 100}%` }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- topic list -->
|
|
<div class="topic-list">
|
|
<TransitionGroup name="topic-appear" tag="div" class="topic-list-inner">
|
|
<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" :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-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>
|
|
<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>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</div>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.course-page {
|
|
min-height: 100dvh;
|
|
background: var(--bg);
|
|
}
|
|
|
|
/* header */
|
|
.course-header {
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 20;
|
|
background: var(--surface);
|
|
}
|
|
|
|
.header-inner {
|
|
max-width: 720px;
|
|
margin: 0 auto;
|
|
padding: 0 1.5rem;
|
|
height: 52px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.back-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
color: var(--text-3);
|
|
font-size: 13px;
|
|
text-decoration: none;
|
|
transition: color 0.15s;
|
|
}
|
|
.back-link:hover { color: var(--text); }
|
|
|
|
.header-logo {
|
|
font-family: "Lora", serif;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
letter-spacing: -0.3px;
|
|
}
|
|
|
|
/* page body */
|
|
.page-body {
|
|
max-width: 720px;
|
|
margin: 0 auto;
|
|
padding: 3.5rem 1.5rem 5rem;
|
|
}
|
|
|
|
/* processing */
|
|
.processing-view { max-width: 400px; margin: 0 auto; margin-bottom: 3rem; }
|
|
.processing-header { margin-bottom: 3rem; }
|
|
|
|
.processing-label {
|
|
font-size: 11px;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
font-weight: 500;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.processing-title {
|
|
font-family: "Lora", serif;
|
|
font-size: 1.5rem;
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* stepper */
|
|
.stepper { display: flex; flex-direction: column; }
|
|
.stepper-item {
|
|
display: grid;
|
|
grid-template-columns: 32px 1px 1fr;
|
|
grid-template-rows: auto auto;
|
|
gap: 0 1.25rem;
|
|
}
|
|
|
|
.stepper-node { grid-column: 1; grid-row: 1; display: flex; justify-content: center; }
|
|
|
|
.stepper-line {
|
|
grid-column: 2; grid-row: 2;
|
|
width: 1px; height: 2.5rem;
|
|
margin: 0 auto; align-self: stretch;
|
|
transition: background 0.4s;
|
|
}
|
|
.stepper-line--done { background: var(--green); }
|
|
.stepper-line--pending { background: var(--border); }
|
|
|
|
.stepper-text {
|
|
grid-column: 3; grid-row: 1 / 3;
|
|
padding-top: 5px; padding-bottom: 2.5rem;
|
|
}
|
|
.stepper-item:last-child .stepper-text { padding-bottom: 0; }
|
|
|
|
.node {
|
|
width: 32px; height: 32px; border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
position: relative;
|
|
}
|
|
.node--done { background: var(--green); color: white; }
|
|
.node--active { background: transparent; border: 1px solid var(--accent); }
|
|
.node--pending { background: transparent; border: 1px solid var(--border); }
|
|
|
|
.node-pulse {
|
|
position: absolute; inset: -4px; border-radius: 50%;
|
|
border: 1px solid var(--accent);
|
|
animation: pulse-ring 2s ease-out infinite; opacity: 0;
|
|
}
|
|
.node-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
background: var(--accent);
|
|
animation: dot-breathe 1.5s ease-in-out infinite;
|
|
}
|
|
.node-dot-sm { width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
|
|
|
|
@keyframes pulse-ring {
|
|
0% { transform: scale(1); opacity: 0.6; }
|
|
100% { transform: scale(1.6); opacity: 0; }
|
|
}
|
|
@keyframes dot-breathe {
|
|
0%, 100% { opacity: 0.4; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
.stepper-title {
|
|
font-size: 14px; font-weight: 500; color: var(--text); line-height: 1.3; transition: color 0.3s;
|
|
}
|
|
.stepper-item--pending .stepper-title { color: var(--border-2); }
|
|
.stepper-item--active .stepper-title { color: var(--accent); }
|
|
|
|
.stepper-sub {
|
|
font-size: 12px; color: var(--text-3); margin-top: 3px; line-height: 1.4;
|
|
}
|
|
.stepper-item--pending .stepper-sub { color: var(--border); }
|
|
|
|
.tip-area { margin-top: 3.5rem; min-height: 36px; text-align: center; }
|
|
.tip-text { font-size: 13px; font-style: italic; color: var(--text-3); line-height: 1.6; padding: 0 1rem; }
|
|
|
|
/* error */
|
|
.error-view { text-align: center; padding: 6rem 0; }
|
|
.error-card {
|
|
display: inline-block;
|
|
border: 1px solid oklch(80% 0.06 15);
|
|
background: oklch(95% 0.03 15);
|
|
border-radius: var(--r-surface);
|
|
padding: 2rem 2.5rem;
|
|
}
|
|
.error-label {
|
|
font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase;
|
|
color: oklch(42% 0.12 15); font-weight: 600; margin-bottom: 8px;
|
|
}
|
|
.error-sub { font-size: 14px; color: var(--text-2); margin-bottom: 16px; }
|
|
.error-back { font-size: 13px; color: var(--accent); text-decoration: none; }
|
|
.error-back:hover { text-decoration: underline; }
|
|
|
|
/* course ready */
|
|
.course-meta { margin-bottom: 2.5rem; }
|
|
|
|
.course-subject {
|
|
font-size: 11px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.title-edit-wrap {
|
|
cursor: text;
|
|
display: inline-block;
|
|
margin-bottom: 10px;
|
|
}
|
|
.title-edit-wrap:hover .title-pencil { opacity: 1; }
|
|
|
|
.course-title {
|
|
font-family: "Lora", serif;
|
|
font-size: 2.25rem;
|
|
line-height: 1.15;
|
|
color: var(--text);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.title-pencil {
|
|
width: 16px; height: 16px; color: var(--text-3);
|
|
opacity: 0; transition: opacity 0.15s; flex-shrink: 0; margin-top: 4px;
|
|
}
|
|
|
|
.title-input {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 2px solid var(--accent-dim);
|
|
outline: none;
|
|
width: 100%;
|
|
padding: 0; margin: 0; display: block;
|
|
}
|
|
|
|
.org-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
.org-chip {
|
|
font-size: 11px; font-weight: 500; color: var(--text-2);
|
|
border: 1px solid var(--border-2); border-radius: 20px;
|
|
padding: 2px 9px; background: var(--surface-2);
|
|
white-space: nowrap; letter-spacing: 0.01em;
|
|
}
|
|
|
|
.course-stats {
|
|
display: flex; align-items: center; gap: 8px; margin-bottom: 16px;
|
|
}
|
|
.stat { font-size: 13px; color: var(--text-2); display: flex; gap: 5px; align-items: center; }
|
|
.stat-num { font-family: "Lora", serif; font-size: 17px; font-weight: 600; color: var(--text); }
|
|
.stat-num--green { color: var(--green); }
|
|
.stat-divider { color: var(--border-2); }
|
|
|
|
.progress-track {
|
|
height: 6px; background: var(--border); border-radius: 99px;
|
|
overflow: hidden; max-width: 320px;
|
|
}
|
|
.progress-fill {
|
|
height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.6s ease;
|
|
}
|
|
|
|
/* topic list */
|
|
.topic-list {}
|
|
|
|
.topic-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.25rem;
|
|
padding: 1.25rem;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--r-item);
|
|
border-left-width: 4px;
|
|
text-decoration: none;
|
|
transition: box-shadow 0.15s, border-color 0.15s;
|
|
}
|
|
|
|
.topic-row--complete { border-left-color: var(--green); }
|
|
.topic-row--available { border-left-color: var(--accent); }
|
|
.topic-row--generating { border-left-color: var(--border); cursor: default; animation: gen-pulse 2.2s ease-in-out infinite; }
|
|
|
|
.topic-row--unlocked:hover {
|
|
box-shadow: 0 4px 16px oklch(0% 0 0 / 0.07);
|
|
}
|
|
.topic-row--unlocked:hover .topic-name { color: var(--accent); }
|
|
.topic-row--unlocked:hover .topic-arrow { color: var(--accent); }
|
|
|
|
.topic-index {
|
|
font-size: 11px; color: var(--text-3); min-width: 1.75rem;
|
|
letter-spacing: 0.05em; flex-shrink: 0;
|
|
}
|
|
|
|
.topic-info { flex: 1; min-width: 0; }
|
|
.topic-name {
|
|
font-size: 15px; font-weight: 500; color: var(--text);
|
|
transition: color 0.2s; display: block;
|
|
}
|
|
|
|
.topic-meta {
|
|
display: flex; align-items: center; gap: 8px; margin-top: 5px;
|
|
}
|
|
|
|
.diff-dots { display: flex; gap: 3px; align-items: center; }
|
|
.diff-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
.diff-dot--filled { background: var(--accent); }
|
|
.diff-dot--empty { background: var(--border); }
|
|
|
|
.diff-label { font-size: 11px; color: var(--text-3); letter-spacing: 0.04em; }
|
|
|
|
.done-badge {
|
|
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
|
|
color: var(--green); background: var(--green-light);
|
|
padding: 1px 7px; border-radius: 20px; font-weight: 500;
|
|
}
|
|
|
|
.branch-dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
background: oklch(65% 0.14 65); flex-shrink: 0; cursor: help;
|
|
}
|
|
|
|
.topic-arrow {
|
|
width: 14px; height: 14px; color: var(--border-2);
|
|
flex-shrink: 0; transition: color 0.2s;
|
|
}
|
|
|
|
/* generating label */
|
|
.generating-label {
|
|
display: flex; align-items: center; gap: 5px;
|
|
font-size: 11px; letter-spacing: 0.06em; color: var(--text-3); flex-shrink: 0;
|
|
}
|
|
|
|
.generating-dot {
|
|
width: 5px; height: 5px; border-radius: 50%;
|
|
background: var(--accent); opacity: 0.6;
|
|
animation: gen-dot-blink 1.4s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes gen-pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.6; }
|
|
}
|
|
|
|
@keyframes gen-dot-blink {
|
|
0%, 100% { opacity: 0.3; }
|
|
50% { opacity: 1; }
|
|
}
|
|
|
|
/* topic appear transition */
|
|
.topic-list-inner { display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
.topic-appear-enter-active { transition: opacity 0.4s ease, transform 0.4s ease; }
|
|
.topic-appear-enter-from { opacity: 0; transform: scale(0.97); }
|
|
|
|
/* tip transition */
|
|
.tip-enter-active, .tip-leave-active { transition: opacity 0.35s ease, transform 0.35s ease; }
|
|
.tip-enter-from { opacity: 0; transform: translateY(5px); }
|
|
.tip-leave-to { opacity: 0; transform: translateY(-5px); }
|
|
|
|
/* audit panel */
|
|
.audit-panel {
|
|
background: var(--text);
|
|
border-radius: var(--r-surface);
|
|
padding: 1.5rem;
|
|
margin-bottom: 2.5rem;
|
|
color: var(--bg);
|
|
}
|
|
|
|
.audit-header {
|
|
display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap;
|
|
}
|
|
|
|
.audit-badge {
|
|
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase;
|
|
background: oklch(65% 0.14 65); color: var(--text);
|
|
padding: 2px 8px; border-radius: 4px; font-weight: 600; flex-shrink: 0;
|
|
}
|
|
|
|
.audit-score {
|
|
font-family: "Lora", serif; font-size: 2rem; line-height: 1; flex-shrink: 0;
|
|
}
|
|
.audit-score-denom { font-size: 14px; color: var(--text-3); }
|
|
|
|
.audit-hide-btn {
|
|
margin-left: auto; background: none; border: 1px solid oklch(35% 0.02 55);
|
|
color: var(--text-3); font-size: 11px; letter-spacing: 0.08em;
|
|
padding: 4px 10px; border-radius: 4px; cursor: pointer; transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.audit-hide-btn:hover { border-color: var(--text-3); color: var(--bg); }
|
|
|
|
.audit-standard-banner {
|
|
font-size: 14px; font-weight: 700; letter-spacing: 0.1em;
|
|
text-align: center; padding: 12px; border-radius: var(--r-sm); margin-bottom: 1.25rem;
|
|
}
|
|
.audit-standard-banner--pass {
|
|
background: rgba(34, 197, 94, 0.15); color: oklch(75% 0.12 155);
|
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
}
|
|
.audit-standard-banner--fail {
|
|
background: rgba(239, 68, 68, 0.15); color: oklch(75% 0.12 15);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.audit-readiness { font-size: 15px; line-height: 1.65; color: oklch(75% 0.01 75); margin-bottom: 1rem; }
|
|
|
|
.unanswered-source { font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
|
.unanswered-gap { color: oklch(75% 0.12 15); }
|
|
|
|
.audit-coverage { font-size: 12px; color: var(--text-3); margin-bottom: 1.5rem; }
|
|
.audit-coverage strong { color: oklch(85% 0.01 75); }
|
|
|
|
.audit-section { border-top: 1px solid oklch(35% 0.02 55); padding-top: 1.25rem; margin-top: 1.25rem; }
|
|
|
|
.audit-section-title {
|
|
font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase;
|
|
color: var(--text-3); margin-bottom: 14px;
|
|
}
|
|
|
|
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
.audit-table th {
|
|
font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
|
color: var(--text-3); padding: 0 8px 8px 0; text-align: left; font-weight: 400; white-space: nowrap;
|
|
}
|
|
.audit-table td {
|
|
padding: 6px 8px 6px 0; color: oklch(80% 0.01 75);
|
|
border-top: 1px solid oklch(35% 0.02 55); vertical-align: top; line-height: 1.5;
|
|
}
|
|
.audit-table tr:first-child td { border-top: none; }
|
|
|
|
.severity-badge { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; padding: 2px 6px; border-radius: 3px; }
|
|
.severity-badge--high { background: rgba(239,68,68,0.15); color: oklch(75% 0.12 15); }
|
|
.severity-badge--medium { background: rgba(245,158,11,0.15); color: oklch(75% 0.14 65); }
|
|
.severity-badge--low { background: rgba(156,163,175,0.12); color: var(--text-3); }
|
|
|
|
.audit-recs { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 8px; }
|
|
.audit-recs li { font-size: 14px; line-height: 1.6; color: oklch(80% 0.01 75); }
|
|
|
|
.audit-collapse-btn {
|
|
display: flex; align-items: center; gap: 8px; background: none; border: none;
|
|
cursor: pointer; font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase;
|
|
color: var(--text-3); padding: 0; transition: color 0.15s;
|
|
}
|
|
.audit-collapse-btn:hover { color: oklch(85% 0.01 75); }
|
|
|
|
.collapse-arrow { font-size: 12px; transition: transform 0.2s; display: inline-block; }
|
|
.collapse-arrow--open { transform: rotate(90deg); }
|
|
|
|
.lesson-quality-list { display: flex; flex-direction: column; gap: 0; margin-top: 14px; }
|
|
.lq-row {
|
|
display: grid; grid-template-columns: 1fr 64px 2fr;
|
|
gap: 12px; padding: 10px 0; border-top: 1px solid oklch(35% 0.02 55); font-size: 13px; align-items: start;
|
|
}
|
|
.lq-row:first-child { border-top: none; }
|
|
.lq-title { color: oklch(85% 0.01 75); font-weight: 500; }
|
|
.lq-score { font-size: 12px; font-weight: 500; text-align: right; }
|
|
.lq-notes { color: var(--text-3); line-height: 1.5; }
|
|
|
|
.audit-show-row { margin-bottom: 2rem; }
|
|
.audit-show-btn {
|
|
background: none; border: 1px solid var(--border);
|
|
color: var(--text-3); font-size: 11px; letter-spacing: 0.1em;
|
|
text-transform: uppercase; padding: 5px 12px; border-radius: var(--r-sm);
|
|
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>
|