initialize project with basic structure and dependencies

This commit is contained in:
ImBenji
2026-04-27 20:56:49 +01:00
parent 59b85afd10
commit 8548717074
85 changed files with 19634 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
@import url("https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=DM+Sans:wght@300;400;500;600&display=swap");
@import "tailwindcss";
:root {
--bg: oklch(97.5% 0.008 78);
--surface: oklch(95.5% 0.012 76);
--surface-2: oklch(93% 0.016 74);
--surface-3: oklch(90% 0.020 72);
--border: oklch(88% 0.022 73);
--border-2: oklch(82% 0.026 70);
--text: oklch(20% 0.020 55);
--text-2: oklch(42% 0.020 60);
--text-3: oklch(60% 0.016 68);
--accent: oklch(54% 0.140 44);
--accent-light: oklch(93% 0.040 76);
--accent-dim: oklch(88% 0.060 72);
--green: oklch(54% 0.130 158);
--green-light: oklch(93% 0.040 155);
--blue: oklch(54% 0.130 255);
--blue-light: oklch(93% 0.035 255);
--sidebar-w: 228px;
--r-card: 16px;
--r-surface: 14px;
--r-item: 12px;
--r-btn: 10px;
--r-sm: 8px;
}
@theme {
--font-serif: "Lora", Georgia, serif;
--font-sans: "DM Sans", system-ui, sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", Menlo, monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
}
html {
font-size: 16px;
}
body {
font-family: "DM Sans", sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font-family: inherit;
}
::selection {
background: var(--accent);
color: white;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }
+198
View File
@@ -0,0 +1,198 @@
<template>
<aside class="sidebar">
<!-- logo -->
<div class="sidebar-logo">
<div class="logo-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 12V4l6-2 6 2v8l-6 2-6-2z" fill="white" opacity="0.9"/>
<path d="M8 2v12M2 4l6 2 6-2" stroke="white" stroke-width="1.2" stroke-opacity="0.6" fill="none"/>
</svg>
</div>
<span class="logo-text">Revisi<span class="logo-accent">.one</span></span>
</div>
<!-- nav -->
<nav class="sidebar-nav">
<NuxtLink to="/" class="nav-item" :class="{ 'nav-item--active': isActive('/') }" exact-active-class="">
<svg class="nav-icon" width="17" height="17" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9.5L10 3l7 6.5V17a1 1 0 01-1 1H4a1 1 0 01-1-1V9.5z"/>
<path d="M7 18V11h6v7"/>
</svg>
Dashboard
</NuxtLink>
<NuxtLink to="/new" class="nav-item" active-class="nav-item--active">
<svg class="nav-icon" width="17" height="17" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M10 4v12M4 10h12"/>
</svg>
New Course
</NuxtLink>
</nav>
<!-- user -->
<div class="sidebar-user">
<div class="user-avatar">
{{ initials }}
</div>
<div class="user-info">
<div class="user-name">{{ name }}</div>
<div class="user-sub">Student</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
const route = useRoute();
// just a placeholder — real auth would provide this
const name = "You";
const initials = "YO";
function isActive(path: string): boolean {
if (path === "/") {
return route.path === "/" || route.path.startsWith("/course/");
}
return route.path.startsWith(path);
}
</script>
<style scoped>
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
}
.sidebar-logo {
padding: 28px 24px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 32px;
height: 32px;
border-radius: var(--r-sm);
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-text {
font-family: "Lora", serif;
font-weight: 600;
font-size: 17px;
letter-spacing: -0.3px;
color: var(--text);
}
.logo-accent {
color: var(--accent);
}
.sidebar-nav {
padding: 16px 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-2);
font-weight: 400;
font-size: 14px;
text-decoration: none;
transition: all 0.15s;
}
.nav-item:hover {
background: var(--surface-2);
}
.nav-item--active {
background: var(--accent-dim) !important;
color: var(--accent) !important;
font-weight: 600;
}
.nav-item--active .nav-icon {
color: var(--accent);
}
.nav-icon {
flex-shrink: 0;
color: var(--text-3);
transition: color 0.15s;
}
.nav-item:hover .nav-icon {
color: var(--text-2);
}
.nav-item--active .nav-icon {
color: var(--accent);
}
.sidebar-user {
padding: 16px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, oklch(72% 0.12 65), oklch(58% 0.14 44));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
line-height: 1.3;
}
.user-sub {
font-size: 12px;
color: var(--text-3);
line-height: 1.3;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
<template>
<div style="display: flex; min-height: 100vh;">
<TheSidebar />
<main style="margin-left: var(--sidebar-w); flex: 1; min-height: 100vh; background: var(--bg); overflow: auto;">
<slot />
</main>
</div>
</template>
+830
View File
@@ -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>
+573
View File
@@ -0,0 +1,573 @@
<script setup lang="ts">
type CourseStatus = "processing" | "ready" | "error";
interface CourseSummary {
id: string
title: string
subject: string
status: CourseStatus
stage: string | null
createdAt: string
topicCount: number
completedCount: number
costAI: number | null
costAudio: number | null
organisation: string | null
}
const { data: courses, refresh } = await useAsyncData(
"courses-list",
() => $fetch<CourseSummary[]>("/api/courses")
);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function maybeStartPolling() {
const hasProcessing = courses.value?.some((c) => c.status === "processing");
if (hasProcessing && !pollTimer) {
pollTimer = setInterval(async () => {
await refresh();
if (!courses.value?.some((c) => c.status === "processing")) {
clearInterval(pollTimer!);
pollTimer = null;
}
}, 4000);
}
}
onMounted(maybeStartPolling);
watch(courses, maybeStartPolling);
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); });
function relativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 2) return "just now";
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
}
const STAGE_LABELS: Record<string, string> = {
parsing_pdfs: "Reading your documents…",
analysing_sources: "Identifying key topics…",
building_curriculum: "Building your curriculum…",
finalising: "Generating lessons & quizzes…",
};
function stageLabel(stage: string | null): string {
return (stage && STAGE_LABELS[stage]) ?? "Processing…";
}
const retrying = ref<Record<string, boolean>>({});
async function retry(courseId: string) {
retrying.value[courseId] = true;
try {
await $fetch(`/api/courses/${courseId}/generate`, { method: "POST" });
await refresh();
maybeStartPolling();
} catch {
// leave error state
} finally {
retrying.value[courseId] = false;
}
}
function progressPct(course: CourseSummary): number {
if (!course.topicCount) return 0;
return Math.round((course.completedCount / course.topicCount) * 100);
}
// deterministic warm color from subject string
function subjectColor(subject: string): string {
const palette = [
"#c9a87c", "#7a9e8e", "#8b7faa", "#b07c7c",
"#7c9db0", "#a89e7c", "#8aaa7c", "#aa7c9e",
];
let hash = 0;
for (let i = 0; i < subject.length; i++) {
hash = (hash * 31 + subject.charCodeAt(i)) & 0xffff;
}
return palette[hash % palette.length] ?? palette[0]!;
}
</script>
<template>
<div class="dashboard">
<!-- header -->
<div class="dash-head">
<div class="dash-head-date">{{ new Date().toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric" }) }}</div>
<h1 class="dash-head-title">Your courses</h1>
</div>
<!-- empty state -->
<div v-if="!courses?.length" class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 20 20" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 3h10a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V4a1 1 0 011-1z"/>
<path d="M7 7h6M7 10h6M7 13h4"/>
</svg>
</div>
<h2 class="empty-heading">No courses yet</h2>
<p class="empty-sub">Upload your lecture slides and past papers to generate a personalised learning path.</p>
<NuxtLink to="/new" class="empty-cta">Create your first course </NuxtLink>
</div>
<!-- grid -->
<div v-else class="course-grid">
<a
v-for="course in courses"
:key="course.id"
:href="course.status !== 'error' ? `/course/${course.id}` : undefined"
class="course-card"
:class="{
'course-card--ready': course.status === 'ready',
'course-card--processing': course.status === 'processing',
'course-card--error': course.status === 'error',
}"
:style="course.status === 'error' ? 'cursor: default;' : ''"
@click.prevent="course.status !== 'error' && $router.push(`/course/${course.id}`)"
>
<!-- color strip derived from subject initials hash for consistency -->
<div class="card-strip" :style="{ background: subjectColor(course.subject) }" />
<div class="card-body">
<!-- top row -->
<div class="card-top">
<div>
<div class="card-subject">{{ course.subject }}</div>
<h3 class="card-title">{{ course.title }}</h3>
<!-- org chip -->
<div v-if="course.organisation" class="card-org-chips">
<span class="org-chip">{{ course.organisation }}</span>
</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>
<!-- bottom -->
<div class="card-bottom">
<template v-if="course.status === 'ready'">
<!-- progress bar -->
<div class="progress-row">
<div class="progress-track">
<div class="progress-fill" :style="{ width: `${progressPct(course)}%`, background: subjectColor(course.subject) }" />
</div>
<span class="progress-label">{{ course.completedCount }}&thinsp;/&thinsp;{{ course.topicCount }}</span>
</div>
<div class="card-footer">
<span class="card-date">Last opened {{ relativeTime(course.createdAt) }}</span>
<div v-if="(course.costAI ?? 0) + (course.costAudio ?? 0) > 0" class="cost-row">
<span>${{ ((course.costAI ?? 0) + (course.costAudio ?? 0)).toFixed(2) }}</span>
</div>
</div>
</template>
<template v-else-if="course.status === 'processing'">
<p class="stage-label">
<span class="stage-dot" />
Generating lessons...
</p>
<span class="card-date">{{ relativeTime(course.createdAt) }}</span>
</template>
<template v-else-if="course.status === 'error'">
<div class="error-row">
<span class="error-msg">Generation failed</span>
<button
class="retry-btn"
:disabled="retrying[course.id]"
@click.prevent="retry(course.id)"
>{{ retrying[course.id] ? 'Retrying…' : 'Retry' }}</button>
</div>
<span class="card-date">{{ relativeTime(course.createdAt) }}</span>
</template>
</div>
</div>
</a>
<!-- add new card -->
<NuxtLink to="/new" class="add-card">
<div class="add-card-circle">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M10 4v12M4 10h12"/>
</svg>
</div>
<span class="add-card-label">Upload new course materials</span>
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.dashboard {
padding: 40px 48px;
max-width: 960px;
}
.dash-head {
margin-bottom: 36px;
}
.dash-head-date {
font-size: 12px;
font-weight: 500;
color: var(--text-3);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.dash-head-title {
font-family: "Lora", serif;
font-size: 28px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.4px;
}
/* empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 6rem 0;
gap: 1rem;
}
.empty-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--accent-light);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
}
.empty-heading {
font-family: "Lora", serif;
font-size: 1.5rem;
font-weight: 600;
color: var(--text);
}
.empty-sub {
font-size: 0.9rem;
line-height: 1.7;
color: var(--text-2);
max-width: 380px;
}
.empty-cta {
margin-top: 0.5rem;
display: inline-block;
background: var(--accent);
color: white;
font-size: 0.875rem;
font-weight: 500;
padding: 0.75rem 1.75rem;
border-radius: var(--r-btn);
text-decoration: none;
transition: opacity 0.15s;
}
.empty-cta:hover { opacity: 0.88; }
/* grid */
.course-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 700px) {
.dashboard { padding: 24px 20px; }
.course-grid { grid-template-columns: 1fr; }
}
/* card */
.course-card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-card);
overflow: hidden;
text-decoration: none;
cursor: default;
transition: border-color 0.18s, transform 0.18s, box-shadow 0.18s;
}
.course-card--ready {
cursor: pointer;
}
.course-card--ready:hover {
border-color: var(--border-2);
transform: translateY(-2px);
box-shadow: 0 8px 28px oklch(0% 0 0 / 0.06);
}
.course-card--error {
border-color: oklch(80% 0.06 15);
}
.card-strip {
height: 6px;
opacity: 0.85;
}
.card-body {
padding: 20px 22px;
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.card-subject {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.07em;
color: var(--text-3);
text-transform: uppercase;
margin-bottom: 4px;
}
.card-title {
font-family: "Lora", serif;
font-weight: 600;
font-size: 16px;
color: var(--text);
line-height: 1.3;
margin-bottom: 8px;
}
.card-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;
}
.status-badge {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-weight: 500;
}
.status-badge--ready {
background: var(--green-light);
color: var(--green);
}
.status-badge--processing {
background: var(--accent-light);
color: var(--accent);
}
.status-badge--error {
background: oklch(93% 0.04 15);
color: oklch(42% 0.12 15);
}
.badge-pulse {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
animation: badge-blink 1.4s ease-in-out infinite;
}
@keyframes badge-blink {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.card-bottom {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
}
.progress-row {
display: flex;
align-items: center;
gap: 10px;
}
.progress-track {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 99px;
transition: width 0.5s ease;
}
.progress-label {
font-size: 12px;
color: var(--text-3);
flex-shrink: 0;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-date {
font-size: 12px;
color: var(--text-3);
}
.cost-row {
font-size: 11px;
color: var(--text-3);
}
.stage-label {
font-size: 13px;
color: var(--accent);
display: flex;
align-items: center;
gap: 6px;
}
.stage-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
animation: badge-blink 1.4s ease-in-out infinite;
}
.course-card--processing {
cursor: pointer;
}
.course-card--processing:hover {
border-color: var(--border-2);
transform: translateY(-2px);
box-shadow: 0 8px 28px oklch(0% 0 0 / 0.06);
}
.error-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.error-msg {
font-size: 13px;
color: oklch(42% 0.12 15);
}
.retry-btn {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
background: none;
border: 1px solid oklch(78% 0.06 15);
color: oklch(42% 0.12 15);
padding: 3px 10px;
border-radius: 20px;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.retry-btn:hover:not(:disabled) { background: oklch(93% 0.04 15); }
.retry-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* add card */
.add-card {
background: transparent;
border: 1.5px dashed var(--border-2);
border-radius: var(--r-card);
padding: 40px 22px;
text-align: center;
cursor: pointer;
color: var(--text-3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-decoration: none;
transition: all 0.18s;
min-height: 160px;
}
.add-card:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-light);
}
.add-card-circle {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1.5px dashed currentColor;
display: flex;
align-items: center;
justify-content: center;
}
.add-card-label {
font-size: 14px;
font-weight: 500;
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
definePageMeta({ layout: false });
const route = useRoute();
const topicId = route.params.id as string;
const { data: lesson, pending, error } = useAsyncData(
`lesson-dl-${topicId}`,
() => $fetch<any>(`/api/topics/${topicId}/lesson`)
);
watch(lesson, (val) => {
if (!val) return;
const content = val.content ?? {};
const steps = (content.steps ?? []).map((step: any) => {
const { audioPath, audioChunks, questionAudioPath, questionAudioChunks, optionAudioPaths, ...rest } = step;
return rest;
});
const payload = {
lessonId: val.id,
topicId,
keyConcepts: content.keyConcepts ?? [],
analogiesUsed: content.analogiesUsed ?? [],
steps,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `lesson-${topicId.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
}, { immediate: true });
</script>
<template>
<div style="font-family:monospace;padding:2rem;color:#666;">
<p v-if="pending">Loading</p>
<p v-else-if="error">Error loading lesson.</p>
<p v-else>Download started. <a :href="`/learn/${topicId}`" style="color:#6366F1;"> Back to lesson</a></p>
</div>
</template>
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
<script setup lang="ts">
type UploadType = "slides" | "past_paper" | "lab_worksheet";
interface UploadedFile {
file: File
uploadId: string | null
detectedType: UploadType | null
overrideType: UploadType | null
uploading: boolean
error: string | null
}
const router = useRouter();
const uploadedFiles = ref<UploadedFile[]>([]);
const editingIndex = ref<number | null>(null);
const dragging = ref(false);
const submitting = ref(false);
const submitted = ref(false);
const formError = ref<string | null>(null);
const typeLabels: Record<UploadType, string> = {
slides: "Lecture Slides",
past_paper: "Past Paper",
lab_worksheet: "Lab Worksheet",
};
function effectiveType(f: UploadedFile): UploadType | null {
return f.overrideType ?? f.detectedType;
}
function onDrop(e: DragEvent) {
dragging.value = false;
addFiles(Array.from(e.dataTransfer?.files ?? []));
}
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
addFiles(Array.from(input.files ?? []));
input.value = "";
}
function addFiles(newFiles: File[]) {
for (const f of newFiles) {
if (f.type !== "application/pdf") continue;
uploadedFiles.value.push({
file: f,
uploadId: null,
detectedType: null,
overrideType: null,
uploading: false,
error: null,
});
}
}
function removeFile(i: number) {
uploadedFiles.value.splice(i, 1);
if (editingIndex.value === i) editingIndex.value = null;
}
async function uploadFile(entry: UploadedFile, courseId: string) {
entry.uploading = true;
entry.error = null;
try {
const fd = new FormData();
fd.append("file", entry.file);
const res = await $fetch<{ uploadId: string; type: UploadType }>(
`/api/courses/${courseId}/upload`,
{ method: "POST", body: fd }
);
entry.uploadId = res.uploadId;
entry.detectedType = res.type;
} catch (err: any) {
entry.error = err?.data?.message ?? "Upload failed";
} finally {
entry.uploading = false;
}
}
async function applyOverride(entry: UploadedFile, newType: UploadType) {
if (!entry.uploadId) return;
entry.overrideType = newType;
editingIndex.value = null;
try {
await $fetch(`/api/uploads/${entry.uploadId}`, {
method: "PATCH",
body: { type: newType },
});
} catch {
entry.overrideType = null;
}
}
async function submit() {
if (uploadedFiles.value.length === 0) {
formError.value = "Add at least one PDF to continue.";
return;
}
formError.value = null;
submitting.value = true;
try {
const { courseId } = await $fetch<{ courseId: string }>("/api/courses", {
method: "POST",
});
for (const entry of uploadedFiles.value) {
await uploadFile(entry, courseId);
}
if (uploadedFiles.value.some((f) => f.error)) {
submitting.value = false;
return;
}
await $fetch(`/api/courses/${courseId}/generate`, { method: "POST" });
submitted.value = true;
setTimeout(() => router.push("/"), 3000);
} catch (err: any) {
formError.value = err?.data?.message ?? "Something went wrong. Please try again.";
submitting.value = false;
}
}
</script>
<template>
<div class="upload-page">
<div class="upload-inner">
<!-- back -->
<NuxtLink to="/" class="back-link">
<svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.5 5l-5 5 5 5"/>
</svg>
Back
</NuxtLink>
<!-- heading -->
<div class="upload-head">
<h1 class="upload-title">Create a new course</h1>
<p class="upload-sub">Upload your materials and we'll build a complete learning path.</p>
</div>
<!-- drop zone -->
<div
class="drop-zone"
:class="{ 'drop-zone--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="($refs.fileInput as HTMLInputElement).click()"
>
<input ref="fileInput" type="file" accept=".pdf" multiple class="hidden" @change="onFileInput" />
<div class="drop-inner">
<div class="drop-icon-wrap">
<svg width="28" height="28" fill="none" viewBox="0 0 24 24" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<p class="drop-text">Drop PDFs here, or <span class="drop-browse">browse files</span></p>
<p class="drop-hint">slides · past papers · lab worksheets</p>
</div>
</div>
<!-- file list -->
<TransitionGroup name="file-list" tag="div" class="file-list">
<div
v-for="(f, i) in uploadedFiles"
:key="f.file.name + i"
class="file-row"
>
<div class="file-row-left">
<div class="file-pdf-label">PDF</div>
<div class="file-info">
<p class="file-name">{{ f.file.name }}</p>
<div v-if="f.uploading" class="detecting-row">
<div class="detecting-dots"><span /><span /><span /></div>
<span class="detecting-text">detecting type…</span>
</div>
<p v-else-if="f.error" class="file-error">{{ f.error }}</p>
<div v-else-if="effectiveType(f)" class="type-row">
<span class="type-badge" :class="`type-badge--${effectiveType(f)}`">
{{ typeLabels[effectiveType(f)!] }}
<span v-if="f.overrideType" class="opacity-50"> · edited</span>
</span>
<button v-if="f.uploadId" class="change-btn" @click.stop="editingIndex = editingIndex === i ? null : i">
{{ editingIndex === i ? 'cancel' : 'change' }}
</button>
</div>
<div v-if="editingIndex === i && f.uploadId" class="type-select-wrap">
<select
class="type-select"
:value="effectiveType(f) ?? ''"
@change="applyOverride(f, ($event.target as HTMLSelectElement).value as UploadType)"
@click.stop
>
<option value="slides">Lecture Slides</option>
<option value="past_paper">Past Paper</option>
<option value="lab_worksheet">Lab Worksheet</option>
</select>
</div>
</div>
</div>
<button class="remove-btn" @click.stop="removeFile(i)">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</TransitionGroup>
<!-- submitted -->
<div v-if="submitted" class="submitted-msg">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--green)" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>Your course is being generated — check back in a few minutes</span>
</div>
<p v-else-if="formError" class="form-error">{{ formError }}</p>
<button
v-if="!submitted"
class="submit-btn"
:class="{ 'submit-btn--loading': submitting }"
:disabled="submitting || uploadedFiles.length === 0"
@click="submit"
>
<span v-if="!submitting">Generate my course →</span>
<span v-else class="btn-loading">
<span class="btn-dots"><span /><span /><span /></span>
Uploading your materials
</span>
</button>
</div>
</div>
</template>
<style scoped>
.upload-page {
padding: 40px 48px;
min-height: 100vh;
}
.upload-inner {
max-width: 560px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-3);
text-decoration: none;
margin-bottom: 32px;
transition: color 0.15s;
}
.back-link:hover { color: var(--text); }
.upload-head {
margin-bottom: 32px;
}
.upload-title {
font-family: "Lora", serif;
font-size: 26px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.4px;
margin-bottom: 6px;
}
.upload-sub {
font-size: 14px;
color: var(--text-2);
line-height: 1.6;
}
.drop-zone {
background: var(--surface);
border: 2px dashed var(--border-2);
border-radius: var(--r-surface);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 16px;
}
.drop-zone:hover { border-color: var(--accent); }
.drop-zone--active {
border-style: solid;
border-color: var(--accent);
background: var(--accent-light);
}
.drop-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
}
.drop-icon-wrap {
margin-bottom: 4px;
}
.drop-text {
font-size: 14px;
color: var(--text-2);
font-weight: 500;
}
.drop-browse {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.drop-hint {
font-size: 12px;
color: var(--text-3);
letter-spacing: 0.04em;
}
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.file-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-btn);
}
.file-row-left {
display: flex;
align-items: flex-start;
gap: 12px;
flex: 1;
min-width: 0;
}
.file-pdf-label {
font-size: 10px;
letter-spacing: 0.08em;
color: var(--text-3);
margin-top: 2px;
flex-shrink: 0;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detecting-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.detecting-text {
font-size: 12px;
color: var(--text-3);
}
.file-error {
font-size: 12px;
color: oklch(42% 0.12 15);
margin-top: 4px;
}
.type-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.type-badge {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 20px;
display: inline-block;
font-weight: 500;
}
.type-badge--slides {
background: var(--accent-light);
color: var(--accent);
border: 1px solid var(--accent-dim);
}
.type-badge--past_paper {
background: oklch(95% 0.03 80);
color: oklch(40% 0.12 65);
border: 1px solid oklch(85% 0.05 75);
}
.type-badge--lab_worksheet {
background: var(--green-light);
color: var(--green);
border: 1px solid oklch(85% 0.05 155);
}
.change-btn {
font-size: 11px;
color: var(--text-3);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
text-decoration: underline;
text-underline-offset: 2px;
}
.change-btn:hover { color: var(--accent); }
.type-select-wrap {
margin-top: 8px;
}
.type-select {
background: var(--surface-2);
border: 1px solid var(--border-2);
color: var(--text);
font-size: 13px;
padding: 6px 10px;
border-radius: var(--r-sm);
outline: none;
}
.remove-btn {
flex-shrink: 0;
color: var(--text-3);
background: none;
border: none;
cursor: pointer;
padding: 2px;
margin-top: 2px;
transition: color 0.15s;
}
.remove-btn:hover { color: var(--text); }
.submitted-msg {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--green);
background: var(--green-light);
border: 1px solid oklch(85% 0.05 155);
border-radius: var(--r-btn);
padding: 12px 16px;
margin-bottom: 16px;
}
.form-error {
font-size: 12px;
color: oklch(42% 0.12 15);
margin-bottom: 12px;
}
.submit-btn {
width: 100%;
background: var(--accent);
color: white;
font-size: 15px;
font-weight: 500;
height: 52px;
border-radius: var(--r-btn);
border: none;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.submit-btn:hover:not(:disabled) { opacity: 0.88; }
.submit-btn:active:not(:disabled) { transform: scale(0.99); }
.submit-btn--loading,
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.detecting-dots, .btn-dots { display: flex; gap: 3px; align-items: center; }
.detecting-dots span, .btn-dots span {
width: 4px; height: 4px; border-radius: 50%;
animation: dot-pulse 1.4s ease-in-out infinite;
}
.detecting-dots span { background: var(--text-3); }
.btn-dots span { background: rgba(255,255,255,0.8); }
.detecting-dots span:nth-child(2), .btn-dots span:nth-child(2) { animation-delay: 0.2s; }
.detecting-dots span:nth-child(3), .btn-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot-pulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.file-list-enter-active { transition: all 0.25s ease; }
.file-list-leave-active { transition: all 0.2s ease; }
.file-list-enter-from { opacity: 0; transform: translateY(-6px); }
.file-list-leave-to { opacity: 0; transform: translateX(10px); }
</style>