initialize project with basic structure and dependencies
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
<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.hasLesson) ?? 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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<span
|
||||
v-if="topic.progress?.lessonComplete && topic.progress?.tookBranches"
|
||||
class="branch-dot"
|
||||
title="You needed a little extra help here — that's completely normal."
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
</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); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user