initialize project with basic structure and dependencies
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }} / {{ 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>
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user