initialize project with basic structure and dependencies
This commit is contained in:
parent
59b85afd10
commit
8548717074
85 changed files with 19634 additions and 0 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
NUXT_OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
|
NUXT_OPENROUTER_MODEL=deepseek/deepseek-v4-flash
|
||||||
|
NUXT_OPENROUTER_CLASSIFICATION_MODEL=deepseek/deepseek-v4-flash
|
||||||
|
NUXT_OPENROUTER_EVALUATOR_MODEL=deepseek/deepseek-r1
|
||||||
|
|
||||||
|
# TTS provider: "elevenlabs" or "fishaudio"
|
||||||
|
NUXT_TTS_PROVIDER=elevenlabs
|
||||||
|
|
||||||
|
NUXT_ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
||||||
|
NUXT_ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
||||||
|
|
||||||
|
NUXT_FISH_AUDIO_API_KEY=your_fish_audio_api_key_here
|
||||||
|
NUXT_FISH_AUDIO_VOICE_ID=your_fish_audio_reference_id_here
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# SQLite db files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Docker data volumes (keep dirs, not content)
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Generated lesson audio
|
||||||
|
public/audio/lessons/
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads /app/data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NITRO_PORT=3000
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "/app/.output/server/index.mjs"]
|
||||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
8
app/app.vue
Normal file
8
app/app.vue
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtRouteAnnouncer />
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
75
app/assets/css/main.css
Normal file
75
app/assets/css/main.css
Normal 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
app/components/TheSidebar.vue
Normal file
198
app/components/TheSidebar.vue
Normal 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
app/layouts/default.vue
Normal file
8
app/layouts/default.vue
Normal 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
app/pages/course/[id].vue
Normal file
830
app/pages/course/[id].vue
Normal 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
app/pages/index.vue
Normal file
573
app/pages/index.vue
Normal 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 }} / {{ 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
app/pages/learn/[id]/dl.vue
Normal file
44
app/pages/learn/[id]/dl.vue
Normal 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>
|
||||||
1508
app/pages/learn/[id]/index.vue
Normal file
1508
app/pages/learn/[id]/index.vue
Normal file
File diff suppressed because it is too large
Load diff
534
app/pages/new.vue
Normal file
534
app/pages/new.vue
Normal 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>
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
revisione:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
environment:
|
||||||
|
- DATABASE_PATH=/app/data/revisione.db
|
||||||
|
networks:
|
||||||
|
- nginx_proxy_manager_default
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx_proxy_manager_default:
|
||||||
|
external: true
|
||||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./server/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
dbCredentials: {
|
||||||
|
url: "./revisione.db",
|
||||||
|
},
|
||||||
|
});
|
||||||
58
drizzle/0000_curved_aqueduct.sql
Normal file
58
drizzle/0000_curved_aqueduct.sql
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
CREATE TABLE `courses` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`subject` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'processing' NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `lessons` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`topic_id` text NOT NULL,
|
||||||
|
`content` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `quiz_questions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`topic_id` text NOT NULL,
|
||||||
|
`question` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`options` text,
|
||||||
|
`answer` text NOT NULL,
|
||||||
|
`explanation` text NOT NULL,
|
||||||
|
FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `topics` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`course_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`description` text NOT NULL,
|
||||||
|
`order` integer NOT NULL,
|
||||||
|
`prerequisite_topic_ids` text DEFAULT '[]' NOT NULL,
|
||||||
|
`difficulty` integer DEFAULT 1 NOT NULL,
|
||||||
|
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `uploads` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`course_id` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`stored_path` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `user_progress` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`course_id` text NOT NULL,
|
||||||
|
`topic_id` text NOT NULL,
|
||||||
|
`lesson_complete` integer DEFAULT false NOT NULL,
|
||||||
|
`quiz_score` integer,
|
||||||
|
`updated_at` text DEFAULT (datetime('now')) NOT NULL,
|
||||||
|
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
|
FOREIGN KEY (`topic_id`) REFERENCES `topics`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
1
drizzle/0001_nervous_the_fury.sql
Normal file
1
drizzle/0001_nervous_the_fury.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `uploads` ADD `extracted_text` text;
|
||||||
1
drizzle/0002_special_korg.sql
Normal file
1
drizzle/0002_special_korg.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `courses` ADD `stage` text;
|
||||||
1
drizzle/0003_adhd_mode_audio.sql
Normal file
1
drizzle/0003_adhd_mode_audio.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `lessons` ADD `audio_path` text;
|
||||||
1
drizzle/0004_adhd_mode_audio_chunks.sql
Normal file
1
drizzle/0004_adhd_mode_audio_chunks.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `lessons` ADD `audio_chunks` text;
|
||||||
1
drizzle/0005_cost_ai.sql
Normal file
1
drizzle/0005_cost_ai.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `courses` ADD `cost_ai` real DEFAULT 0;
|
||||||
1
drizzle/0006_cost_audio.sql
Normal file
1
drizzle/0006_cost_audio.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `courses` ADD `cost_audio` real DEFAULT 0;
|
||||||
1
drizzle/0007_branch_took.sql
Normal file
1
drizzle/0007_branch_took.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE user_progress ADD COLUMN took_branches INTEGER NOT NULL DEFAULT 0;
|
||||||
1
drizzle/0008_branch_count.sql
Normal file
1
drizzle/0008_branch_count.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE user_progress ADD COLUMN branch_count INTEGER NOT NULL DEFAULT 0;
|
||||||
1
drizzle/0009_audit_report.sql
Normal file
1
drizzle/0009_audit_report.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE courses ADD COLUMN audit_report TEXT;
|
||||||
1
drizzle/0010_audit_score.sql
Normal file
1
drizzle/0010_audit_score.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE courses ADD COLUMN audit_score INTEGER;
|
||||||
1
drizzle/0011_tts_provider.sql
Normal file
1
drizzle/0011_tts_provider.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `lessons` ADD `tts_provider` text;
|
||||||
1
drizzle/0012_organisation.sql
Normal file
1
drizzle/0012_organisation.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `courses` ADD `organisation` text;
|
||||||
1
drizzle/0013_relevant_files.sql
Normal file
1
drizzle/0013_relevant_files.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `topics` ADD `relevant_files` text;
|
||||||
414
drizzle/meta/0000_snapshot.json
Normal file
414
drizzle/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "42b7d448-5f22-45aa-850d-dde77533d699",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"courses": {
|
||||||
|
"name": "courses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'processing'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"lessons": {
|
||||||
|
"name": "lessons",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"lessons_topic_id_topics_id_fk": {
|
||||||
|
"name": "lessons_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "lessons",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"quiz_questions": {
|
||||||
|
"name": "quiz_questions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"question": {
|
||||||
|
"name": "question",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"name": "options",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"answer": {
|
||||||
|
"name": "answer",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"name": "explanation",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"quiz_questions_topic_id_topics_id_fk": {
|
||||||
|
"name": "quiz_questions_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "quiz_questions",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"name": "topics",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prerequisite_topic_ids": {
|
||||||
|
"name": "prerequisite_topic_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"topics_course_id_courses_id_fk": {
|
||||||
|
"name": "topics_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "topics",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"name": "uploads",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stored_path": {
|
||||||
|
"name": "stored_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"uploads_course_id_courses_id_fk": {
|
||||||
|
"name": "uploads_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "uploads",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_progress": {
|
||||||
|
"name": "user_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lesson_complete": {
|
||||||
|
"name": "lesson_complete",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"quiz_score": {
|
||||||
|
"name": "quiz_score",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_progress_course_id_courses_id_fk": {
|
||||||
|
"name": "user_progress_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_progress_topic_id_topics_id_fk": {
|
||||||
|
"name": "user_progress_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
421
drizzle/meta/0001_snapshot.json
Normal file
421
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "58920b2c-2c0f-42b4-a675-2b30e03bd747",
|
||||||
|
"prevId": "42b7d448-5f22-45aa-850d-dde77533d699",
|
||||||
|
"tables": {
|
||||||
|
"courses": {
|
||||||
|
"name": "courses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'processing'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"lessons": {
|
||||||
|
"name": "lessons",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"lessons_topic_id_topics_id_fk": {
|
||||||
|
"name": "lessons_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "lessons",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"quiz_questions": {
|
||||||
|
"name": "quiz_questions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"question": {
|
||||||
|
"name": "question",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"name": "options",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"answer": {
|
||||||
|
"name": "answer",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"name": "explanation",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"quiz_questions_topic_id_topics_id_fk": {
|
||||||
|
"name": "quiz_questions_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "quiz_questions",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"name": "topics",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prerequisite_topic_ids": {
|
||||||
|
"name": "prerequisite_topic_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"topics_course_id_courses_id_fk": {
|
||||||
|
"name": "topics_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "topics",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"name": "uploads",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stored_path": {
|
||||||
|
"name": "stored_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"extracted_text": {
|
||||||
|
"name": "extracted_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"uploads_course_id_courses_id_fk": {
|
||||||
|
"name": "uploads_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "uploads",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_progress": {
|
||||||
|
"name": "user_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lesson_complete": {
|
||||||
|
"name": "lesson_complete",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"quiz_score": {
|
||||||
|
"name": "quiz_score",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_progress_course_id_courses_id_fk": {
|
||||||
|
"name": "user_progress_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_progress_topic_id_topics_id_fk": {
|
||||||
|
"name": "user_progress_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
428
drizzle/meta/0002_snapshot.json
Normal file
428
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "85b190a4-a50e-4a71-a0df-ac5c1ac5c869",
|
||||||
|
"prevId": "58920b2c-2c0f-42b4-a675-2b30e03bd747",
|
||||||
|
"tables": {
|
||||||
|
"courses": {
|
||||||
|
"name": "courses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'processing'"
|
||||||
|
},
|
||||||
|
"stage": {
|
||||||
|
"name": "stage",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"lessons": {
|
||||||
|
"name": "lessons",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"lessons_topic_id_topics_id_fk": {
|
||||||
|
"name": "lessons_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "lessons",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"quiz_questions": {
|
||||||
|
"name": "quiz_questions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"question": {
|
||||||
|
"name": "question",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"name": "options",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"answer": {
|
||||||
|
"name": "answer",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"name": "explanation",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"quiz_questions_topic_id_topics_id_fk": {
|
||||||
|
"name": "quiz_questions_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "quiz_questions",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"name": "topics",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "order",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"prerequisite_topic_ids": {
|
||||||
|
"name": "prerequisite_topic_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"topics_course_id_courses_id_fk": {
|
||||||
|
"name": "topics_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "topics",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"uploads": {
|
||||||
|
"name": "uploads",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stored_path": {
|
||||||
|
"name": "stored_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"extracted_text": {
|
||||||
|
"name": "extracted_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"uploads_course_id_courses_id_fk": {
|
||||||
|
"name": "uploads_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "uploads",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_progress": {
|
||||||
|
"name": "user_progress",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"course_id": {
|
||||||
|
"name": "course_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"topic_id": {
|
||||||
|
"name": "topic_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lesson_complete": {
|
||||||
|
"name": "lesson_complete",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"quiz_score": {
|
||||||
|
"name": "quiz_score",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(datetime('now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_progress_course_id_courses_id_fk": {
|
||||||
|
"name": "user_progress_course_id_courses_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "courses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"course_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_progress_topic_id_topics_id_fk": {
|
||||||
|
"name": "user_progress_topic_id_topics_id_fk",
|
||||||
|
"tableFrom": "user_progress",
|
||||||
|
"tableTo": "topics",
|
||||||
|
"columnsFrom": [
|
||||||
|
"topic_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
drizzle/meta/_journal.json
Normal file
104
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777109155026,
|
||||||
|
"tag": "0000_curved_aqueduct",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777110758287,
|
||||||
|
"tag": "0001_nervous_the_fury",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777111595079,
|
||||||
|
"tag": "0002_special_korg",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000000,
|
||||||
|
"tag": "0003_adhd_mode_audio",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000001,
|
||||||
|
"tag": "0004_adhd_mode_audio_chunks",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000002,
|
||||||
|
"tag": "0005_cost_ai",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000003,
|
||||||
|
"tag": "0006_cost_audio",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000004,
|
||||||
|
"tag": "0007_branch_took",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000005,
|
||||||
|
"tag": "0008_branch_count",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000006,
|
||||||
|
"tag": "0009_audit_report",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000007,
|
||||||
|
"tag": "0010_audit_score",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000008,
|
||||||
|
"tag": "0011_tts_provider",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000009,
|
||||||
|
"tag": "0012_organisation",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777200000010,
|
||||||
|
"tag": "0013_relevant_files",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
nuxt.config.ts
Normal file
37
nuxt.config.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: "Revisi.one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
future: { compatibilityVersion: 4 },
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
|
||||||
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
openrouterApiKey: "",
|
||||||
|
openrouterModel: "deepseek/deepseek-v4-flash",
|
||||||
|
openrouterClassificationModel: "deepseek/deepseek-v4-flash",
|
||||||
|
openrouterEvaluatorModel: "deepseek/deepseek-r1",
|
||||||
|
ttsProvider: "elevenlabs",
|
||||||
|
elevenlabsApiKey: "",
|
||||||
|
elevenlabsVoiceId: "21m00Tcm4TlvDq8ikWAM",
|
||||||
|
fishAudioApiKey: "",
|
||||||
|
fishAudioVoiceId: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
experimental: {
|
||||||
|
asyncContext: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12216
package-lock.json
generated
Normal file
12216
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/audio/branch-transition.mp3
Normal file
BIN
public/audio/branch-transition.mp3
Normal file
Binary file not shown.
BIN
public/audio/correct_1.mp3
Normal file
BIN
public/audio/correct_1.mp3
Normal file
Binary file not shown.
BIN
public/audio/correct_2.mp3
Normal file
BIN
public/audio/correct_2.mp3
Normal file
Binary file not shown.
BIN
public/audio/correct_3.mp3
Normal file
BIN
public/audio/correct_3.mp3
Normal file
Binary file not shown.
BIN
public/audio/correct_4.mp3
Normal file
BIN
public/audio/correct_4.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/A_1.mp3
Normal file
BIN
public/audio/labels/A_1.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/A_2.mp3
Normal file
BIN
public/audio/labels/A_2.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/A_3.mp3
Normal file
BIN
public/audio/labels/A_3.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/A_4.mp3
Normal file
BIN
public/audio/labels/A_4.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/B_1.mp3
Normal file
BIN
public/audio/labels/B_1.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/B_2.mp3
Normal file
BIN
public/audio/labels/B_2.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/B_3.mp3
Normal file
BIN
public/audio/labels/B_3.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/B_4.mp3
Normal file
BIN
public/audio/labels/B_4.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/C_1.mp3
Normal file
BIN
public/audio/labels/C_1.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/C_2.mp3
Normal file
BIN
public/audio/labels/C_2.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/C_3.mp3
Normal file
BIN
public/audio/labels/C_3.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/C_4.mp3
Normal file
BIN
public/audio/labels/C_4.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/D_1.mp3
Normal file
BIN
public/audio/labels/D_1.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/D_2.mp3
Normal file
BIN
public/audio/labels/D_2.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/D_3.mp3
Normal file
BIN
public/audio/labels/D_3.mp3
Normal file
Binary file not shown.
BIN
public/audio/labels/D_4.mp3
Normal file
BIN
public/audio/labels/D_4.mp3
Normal file
Binary file not shown.
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
9
scripts/rebuild.sh
Executable file
9
scripts/rebuild.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
sudo docker compose down
|
||||||
|
sudo env DOCKER_BUILDKIT=0 COMPOSE_DOCKER_CLI_BUILD=0 docker compose build --no-cache
|
||||||
|
sudo docker compose up -d --no-build
|
||||||
|
sudo docker compose logs --tail=50
|
||||||
20
server/api/courses/[id]/generate.post.ts
Normal file
20
server/api/courses/[id]/generate.post.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { courses } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { generateCourseInBackground } from "../../../utils/generateCourse";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||||
|
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||||
|
|
||||||
|
await db.update(courses).set({ status: "processing", stage: "parsing_pdfs" }).where(eq(courses.id, id));
|
||||||
|
|
||||||
|
// fire and forget — response returns immediately
|
||||||
|
console.log(`[revisione:${id.slice(0, 8)}] background generation triggered for "${course.title}"`);
|
||||||
|
generateCourseInBackground(id).catch(() => {});
|
||||||
|
|
||||||
|
setResponseStatus(event, 202);
|
||||||
|
return { message: "Course generation started" };
|
||||||
|
});
|
||||||
43
server/api/courses/[id]/index.get.ts
Normal file
43
server/api/courses/[id]/index.get.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { courses, topics, userProgress, lessons } from "../../../db/schema";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const course = await db.query.courses.findFirst({
|
||||||
|
where: eq(courses.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||||
|
|
||||||
|
const topicRows = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, id),
|
||||||
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressRows = await db.query.userProgress.findMany({
|
||||||
|
where: eq(userProgress.courseId, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressMap: Record<string, typeof progressRows[0]> = {};
|
||||||
|
for (const p of progressRows) progressMap[p.topicId] = p;
|
||||||
|
|
||||||
|
const topicIds = topicRows.map((t) => t.id);
|
||||||
|
|
||||||
|
const lessonRows = topicIds.length
|
||||||
|
? await db.query.lessons.findMany({ where: inArray(lessons.topicId, topicIds) })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const lessonTopicIds = new Set(lessonRows.map((l) => l.topicId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...course,
|
||||||
|
topics: topicRows.map((t) => ({
|
||||||
|
...t,
|
||||||
|
prerequisiteTopicIds: JSON.parse(t.prerequisiteTopicIds ?? "[]"),
|
||||||
|
progress: progressMap[t.id] ?? null,
|
||||||
|
hasLesson: lessonTopicIds.has(t.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
27
server/api/courses/[id]/index.patch.ts
Normal file
27
server/api/courses/[id]/index.patch.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { courses } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||||
|
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||||
|
|
||||||
|
const updates: Partial<typeof courses.$inferInsert> = {};
|
||||||
|
|
||||||
|
if (body.title !== undefined) {
|
||||||
|
if (!body.title.trim()) throw createError({ statusCode: 400, message: "Title cannot be empty" });
|
||||||
|
updates.title = body.title.trim();
|
||||||
|
}
|
||||||
|
if (body.subject !== undefined) updates.subject = body.subject;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
throw createError({ statusCode: 400, message: "Nothing to update" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(courses).set(updates).where(eq(courses.id, id));
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
52
server/api/courses/[id]/upload.post.ts
Normal file
52
server/api/courses/[id]/upload.post.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { uploads, courses } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { writeFile, mkdir } from "fs/promises";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { parsePdf } from "../../../utils/parsePdf";
|
||||||
|
import { detectUploadType } from "../../../utils/detectUploadType";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, id) });
|
||||||
|
if (!course) throw createError({ statusCode: 404, message: "Course not found" });
|
||||||
|
|
||||||
|
const formData = await readFormData(event);
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw createError({ statusCode: 400, message: "file is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = resolve(process.cwd(), "uploads", id);
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const uploadId = randomUUID();
|
||||||
|
const safeFilename = `${uploadId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
|
||||||
|
const storedPath = resolve(uploadDir, safeFilename);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(storedPath, buffer);
|
||||||
|
|
||||||
|
let extractedText: string | null = null;
|
||||||
|
try {
|
||||||
|
extractedText = await parsePdf(buffer);
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedType = await detectUploadType(file.name, extractedText ?? "");
|
||||||
|
|
||||||
|
await db.insert(uploads).values({
|
||||||
|
id: uploadId,
|
||||||
|
courseId: id,
|
||||||
|
filename: file.name,
|
||||||
|
type: detectedType,
|
||||||
|
storedPath,
|
||||||
|
extractedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { uploadId, filename: file.name, type: detectedType };
|
||||||
|
});
|
||||||
35
server/api/courses/index.get.ts
Normal file
35
server/api/courses/index.get.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { courses, topics, userProgress } from "../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const allCourses = await db.query.courses.findMany({
|
||||||
|
orderBy: (c, { desc }) => desc(c.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const course of allCourses) {
|
||||||
|
const topicRows = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, course.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topicCount = topicRows.length;
|
||||||
|
|
||||||
|
let completedCount = 0;
|
||||||
|
if (topicCount > 0) {
|
||||||
|
const progressRows = await db.query.userProgress.findMany({
|
||||||
|
where: eq(userProgress.courseId, course.id),
|
||||||
|
});
|
||||||
|
completedCount = progressRows.filter((p) => p.lessonComplete).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
...course,
|
||||||
|
topicCount,
|
||||||
|
completedCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
16
server/api/courses/index.post.ts
Normal file
16
server/api/courses/index.post.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { courses } from "../../db/schema";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(courses).values({
|
||||||
|
id,
|
||||||
|
title: "Untitled Course",
|
||||||
|
subject: "Unknown",
|
||||||
|
status: "processing",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { courseId: id };
|
||||||
|
});
|
||||||
18
server/api/topics/[id]/lesson.get.ts
Normal file
18
server/api/topics/[id]/lesson.get.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { topics, lessons } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
|
||||||
|
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
|
||||||
|
|
||||||
|
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, id) });
|
||||||
|
if (!lesson) throw createError({ statusCode: 404, message: "Lesson not yet generated" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...lesson,
|
||||||
|
content: JSON.parse(lesson.content),
|
||||||
|
};
|
||||||
|
});
|
||||||
46
server/api/topics/[id]/progress.post.ts
Normal file
46
server/api/topics/[id]/progress.post.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { topics, userProgress } from "../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
|
||||||
|
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { lessonComplete, quizScore, tookBranches, branchCount } = body ?? {};
|
||||||
|
|
||||||
|
const existing = await db.query.userProgress.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(userProgress.topicId, id),
|
||||||
|
eq(userProgress.courseId, topic.courseId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(userProgress)
|
||||||
|
.set({
|
||||||
|
lessonComplete: lessonComplete ?? existing.lessonComplete,
|
||||||
|
quizScore: quizScore ?? existing.quizScore,
|
||||||
|
tookBranches: tookBranches ?? existing.tookBranches,
|
||||||
|
branchCount: branchCount ?? existing.branchCount,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(userProgress.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(userProgress).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
courseId: topic.courseId,
|
||||||
|
topicId: id,
|
||||||
|
lessonComplete: lessonComplete ?? false,
|
||||||
|
quizScore: quizScore ?? null,
|
||||||
|
tookBranches: tookBranches ?? false,
|
||||||
|
branchCount: branchCount ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
21
server/api/topics/[id]/quiz.get.ts
Normal file
21
server/api/topics/[id]/quiz.get.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { db } from "../../../db/index";
|
||||||
|
import { topics, quizQuestions } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const topic = await db.query.topics.findFirst({ where: eq(topics.id, id) });
|
||||||
|
if (!topic) throw createError({ statusCode: 404, message: "Topic not found" });
|
||||||
|
|
||||||
|
const questions = await db.query.quizQuestions.findMany({
|
||||||
|
where: eq(quizQuestions.topicId, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (questions.length === 0) throw createError({ statusCode: 404, message: "Quiz not yet generated" });
|
||||||
|
|
||||||
|
return questions.map((q) => ({
|
||||||
|
...q,
|
||||||
|
options: q.options ? JSON.parse(q.options) : null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
25
server/api/uploads/[id].patch.ts
Normal file
25
server/api/uploads/[id].patch.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { db } from "../../db/index";
|
||||||
|
import { uploads } from "../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = getRouterParam(event, "id")!;
|
||||||
|
|
||||||
|
const upload = await db.query.uploads.findFirst({ where: eq(uploads.id, id) });
|
||||||
|
if (!upload) throw createError({ statusCode: 404, message: "Upload not found" });
|
||||||
|
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { type } = body ?? {};
|
||||||
|
|
||||||
|
const valid = ["slides", "past_paper", "lab_worksheet"];
|
||||||
|
if (!type || !valid.includes(type)) {
|
||||||
|
throw createError({ statusCode: 400, message: "type must be slides, past_paper, or lab_worksheet" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(uploads)
|
||||||
|
.set({ type: type as "slides" | "past_paper" | "lab_worksheet" })
|
||||||
|
.where(eq(uploads.id, id));
|
||||||
|
|
||||||
|
return { ok: true, type };
|
||||||
|
});
|
||||||
12
server/db/index.ts
Normal file
12
server/db/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH || resolve(process.cwd(), "revisione.db");
|
||||||
|
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
8
server/db/migrate.ts
Normal file
8
server/db/migrate.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { db } from "./index";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
const migrationsFolder = resolve(process.cwd(), "drizzle");
|
||||||
|
|
||||||
|
migrate(db, { migrationsFolder });
|
||||||
|
console.log("Migrations complete");
|
||||||
92
server/db/schema.ts
Normal file
92
server/db/schema.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const courses = sqliteTable("courses", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
subject: text("subject").notNull(),
|
||||||
|
status: text("status", { enum: ["processing", "ready", "error"] })
|
||||||
|
.notNull()
|
||||||
|
.default("processing"),
|
||||||
|
stage: text("stage", {
|
||||||
|
enum: ["parsing_pdfs", "analysing_sources", "building_curriculum", "finalising", "ready", "error"],
|
||||||
|
}),
|
||||||
|
costAI: real("cost_ai").default(0),
|
||||||
|
costAudio: real("cost_audio").default(0),
|
||||||
|
auditReport: text("audit_report"),
|
||||||
|
auditScore: integer("audit_score"),
|
||||||
|
organisation: text("organisation"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploads = sqliteTable("uploads", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
courseId: text("course_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => courses.id),
|
||||||
|
filename: text("filename").notNull(),
|
||||||
|
type: text("type", { enum: ["slides", "past_paper", "lab_worksheet"] }).notNull(),
|
||||||
|
storedPath: text("stored_path").notNull(),
|
||||||
|
extractedText: text("extracted_text"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const topics = sqliteTable("topics", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
courseId: text("course_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => courses.id),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description").notNull(),
|
||||||
|
order: integer("order").notNull(),
|
||||||
|
prerequisiteTopicIds: text("prerequisite_topic_ids").notNull().default("[]"),
|
||||||
|
difficulty: integer("difficulty").notNull().default(1),
|
||||||
|
relevantFiles: text("relevant_files"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const lessons = sqliteTable("lessons", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
topicId: text("topic_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => topics.id),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
ttsProvider: text("tts_provider"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quizQuestions = sqliteTable("quiz_questions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
topicId: text("topic_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => topics.id),
|
||||||
|
question: text("question").notNull(),
|
||||||
|
type: text("type", { enum: ["mcq", "short_answer", "worked"] }).notNull(),
|
||||||
|
options: text("options"),
|
||||||
|
answer: text("answer").notNull(),
|
||||||
|
explanation: text("explanation").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userProgress = sqliteTable("user_progress", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
courseId: text("course_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => courses.id),
|
||||||
|
topicId: text("topic_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => topics.id),
|
||||||
|
lessonComplete: integer("lesson_complete", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
quizScore: integer("quiz_score"),
|
||||||
|
tookBranches: integer("took_branches", { mode: "boolean" }).notNull().default(false),
|
||||||
|
branchCount: integer("branch_count").notNull().default(0),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(datetime('now'))`),
|
||||||
|
});
|
||||||
7
server/plugins/generateLabels.ts
Normal file
7
server/plugins/generateLabels.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { generateLabels } from "../utils/generateLabels";
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async () => {
|
||||||
|
generateLabels().catch((err) => {
|
||||||
|
console.error("[labels] startup generation failed:", err?.message ?? err);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
server/plugins/migrate.ts
Normal file
9
server/plugins/migrate.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async () => {
|
||||||
|
const migrationsFolder = resolve(process.cwd(), "drizzle");
|
||||||
|
migrate(db, { migrationsFolder });
|
||||||
|
console.log("[revisione] db migrations applied");
|
||||||
|
});
|
||||||
29
server/plugins/resumeGeneration.ts
Normal file
29
server/plugins/resumeGeneration.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { courses } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { generateCourseInBackground } from "../utils/generateCourse";
|
||||||
|
|
||||||
|
export default defineNitroPlugin(async () => {
|
||||||
|
console.log("[revisione] resumeGeneration plugin started");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stuck = await db.query.courses.findMany({
|
||||||
|
where: eq(courses.status, "processing"),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[revisione] query complete — found ${stuck.length} course(s)`);
|
||||||
|
|
||||||
|
if (stuck.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`[revisione] found ${stuck.length} course(s) stuck in processing — resuming`);
|
||||||
|
|
||||||
|
for (const course of stuck) {
|
||||||
|
console.log(`[revisione] resuming "${course.title}" (${course.id.slice(0, 8)})`);
|
||||||
|
generateCourseInBackground(course.id).catch((err) => {
|
||||||
|
console.error(`[revisione] background generation threw:`, err?.message ?? err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[revisione] resumeGeneration plugin error:", err?.message ?? err);
|
||||||
|
}
|
||||||
|
});
|
||||||
196
server/utils/auditCourse.ts
Normal file
196
server/utils/auditCourse.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { askAI } from "./openrouter";
|
||||||
|
|
||||||
|
function parseJSON<T>(raw: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSteps(steps: any[]): string {
|
||||||
|
return steps.map((s: any, i: number) => {
|
||||||
|
const lines: string[] = [` Step ${i + 1} [${s.type.toUpperCase()}]`];
|
||||||
|
|
||||||
|
if (s.title) lines.push(` Title: ${s.title}`);
|
||||||
|
if (s.body) lines.push(` Body: ${s.body}`);
|
||||||
|
if (s.callout) lines.push(` Callout: ${s.callout}`);
|
||||||
|
if (Array.isArray(s.bullets)) {
|
||||||
|
lines.push(` Bullets:\n${s.bullets.map((b: string) => ` - ${b}`).join("\n")}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(s.options)) {
|
||||||
|
lines.push(` Options:\n${s.options.map((o: string, oi: number) => ` ${["A","B","C","D"][oi]}: ${o}`).join("\n")}`);
|
||||||
|
lines.push(` Answer: ${s.answer}`);
|
||||||
|
if (s.explanation) lines.push(` Explanation: ${s.explanation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// include branches if present
|
||||||
|
if (s.branches && typeof s.branches === "object") {
|
||||||
|
for (const [wrongOpt, branch] of Object.entries(s.branches as Record<string, any>)) {
|
||||||
|
lines.push(` Branch for wrong answer "${wrongOpt}":`);
|
||||||
|
for (const bs of branch.steps ?? []) {
|
||||||
|
lines.push(` [BRANCH CONCEPT] ${bs.title ?? ""}: ${bs.body ?? ""}`);
|
||||||
|
}
|
||||||
|
if (branch.confirmQuestion) {
|
||||||
|
const cq = branch.confirmQuestion;
|
||||||
|
lines.push(` [CONFIRM QUESTION] ${cq.body}`);
|
||||||
|
if (Array.isArray(cq.options)) {
|
||||||
|
lines.push(` Options: ${cq.options.join(" | ")}`);
|
||||||
|
}
|
||||||
|
lines.push(` Answer: ${cq.answer}`);
|
||||||
|
}
|
||||||
|
if (branch.hardStop) {
|
||||||
|
lines.push(` [HARD STOP] ${branch.hardStop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auditCourse(courseId: string): Promise<void> {
|
||||||
|
console.log(`[audit:${courseId.slice(0, 8)}] starting post-generation audit`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
||||||
|
if (!course) throw new Error("Course not found");
|
||||||
|
|
||||||
|
const uploadRows = await db.query.uploads.findMany({ where: eq(uploads.courseId, courseId) });
|
||||||
|
const topicRows = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, courseId),
|
||||||
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonMap: Record<string, any> = {};
|
||||||
|
for (const topic of topicRows) {
|
||||||
|
const lesson = await db.query.lessons.findFirst({ where: eq(lessons.topicId, topic.id) });
|
||||||
|
if (lesson) lessonMap[topic.id] = lesson;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quizMap: Record<string, any[]> = {};
|
||||||
|
for (const topic of topicRows) {
|
||||||
|
quizMap[topic.id] = await db.query.quizQuestions.findMany({ where: eq(quizQuestions.topicId, topic.id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// full source text — no truncation
|
||||||
|
const primaryParts: string[] = [];
|
||||||
|
const secondaryParts: string[] = [];
|
||||||
|
for (const u of uploadRows) {
|
||||||
|
if (!u.extractedText) continue;
|
||||||
|
if (u.type === "past_paper" || u.type === "lab_worksheet") {
|
||||||
|
primaryParts.push(`--- ${u.filename} (${u.type}) ---\n${u.extractedText}`);
|
||||||
|
} else {
|
||||||
|
secondaryParts.push(`--- ${u.filename} (${u.type}) ---\n${u.extractedText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryText = primaryParts.join("\n\n") || "(none)";
|
||||||
|
const secondaryText = secondaryParts.join("\n\n") || "(none)";
|
||||||
|
|
||||||
|
// topics block
|
||||||
|
const topicsBlock = topicRows
|
||||||
|
.map((t) => `${t.order + 1}. ${t.title} (difficulty ${t.difficulty}/5)\n ${t.description}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// full lesson block — every step, every branch, every confirm question, every hard stop
|
||||||
|
const lessonsBlock = topicRows.map((t) => {
|
||||||
|
const lesson = lessonMap[t.id];
|
||||||
|
if (!lesson) return `Topic: ${t.title}\n (no lesson generated)`;
|
||||||
|
let content: any = {};
|
||||||
|
try { content = JSON.parse(lesson.content); } catch {}
|
||||||
|
const keyConcepts = (content.keyConcepts ?? []).join(", ");
|
||||||
|
const steps = renderSteps(content.steps ?? []);
|
||||||
|
return `Topic: ${t.title}\nKey concepts: ${keyConcepts}\n${steps}`;
|
||||||
|
}).join("\n\n---\n\n");
|
||||||
|
|
||||||
|
// full quiz block — question, all options, answer, explanation
|
||||||
|
const quizBlock = topicRows.map((t) => {
|
||||||
|
const qs = quizMap[t.id] ?? [];
|
||||||
|
if (qs.length === 0) return `Topic: ${t.title}\n (no quiz questions)`;
|
||||||
|
const qList = qs.map((q: any, i: number) => {
|
||||||
|
const lines = [` Q${i + 1} [${q.type}]: ${q.question}`];
|
||||||
|
if (q.options) {
|
||||||
|
try {
|
||||||
|
const opts: string[] = JSON.parse(q.options);
|
||||||
|
lines.push(` Options: ${opts.join(" | ")}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
lines.push(` Answer: ${q.answer}`);
|
||||||
|
lines.push(` Explanation: ${q.explanation}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}).join("\n");
|
||||||
|
return `Topic: ${t.title}\n${qList}`;
|
||||||
|
}).join("\n\n---\n\n");
|
||||||
|
|
||||||
|
const prompt = `You are auditing an AI-generated course against a single standard: can a student who completes this course answer every question in every past paper and lab worksheet provided?
|
||||||
|
|
||||||
|
This is a pass/fail standard, not a score. However, also provide a score for tracking progress toward that standard.
|
||||||
|
|
||||||
|
PRIMARY SOURCES (past papers + lab worksheets):
|
||||||
|
${primaryText}
|
||||||
|
|
||||||
|
SECONDARY SOURCES (lecture slides):
|
||||||
|
${secondaryText}
|
||||||
|
|
||||||
|
THE GENERATED COURSE:
|
||||||
|
${course.title}
|
||||||
|
|
||||||
|
TOPICS COVERED (${topicRows.length} topics):
|
||||||
|
${topicsBlock}
|
||||||
|
|
||||||
|
LESSONS GENERATED:
|
||||||
|
${lessonsBlock}
|
||||||
|
|
||||||
|
QUIZ QUESTIONS:
|
||||||
|
${quizBlock}
|
||||||
|
|
||||||
|
YOUR AUDIT PROCESS:
|
||||||
|
1. Go through every past paper question one by one. For each question, determine: could a student who completed this course answer it fully and correctly? If not, why not — what is missing or underdeveloped?
|
||||||
|
2. Go through every lab worksheet task one by one. Same question.
|
||||||
|
3. Go through every concept in the lecture slides. Is it covered in the course to a level of full understanding?
|
||||||
|
4. Identify every gap — topics missing, algorithms not taught to implementation level, calculations not drilled, pseudocode not covered, procedures not walked through.
|
||||||
|
|
||||||
|
Return only valid JSON, no markdown:
|
||||||
|
{
|
||||||
|
"overallScore": 0-100,
|
||||||
|
"passesStandard": true/false,
|
||||||
|
"examReadiness": "honest plain English verdict",
|
||||||
|
"unansweredPaperQuestions": [
|
||||||
|
{ "source": "2023 Q2(a)", "question": "brief description", "gap": "what the course fails to teach that would be needed" }
|
||||||
|
],
|
||||||
|
"coverageAnalysis": { "totalExaminedTopics": 0, "coveredTopics": 0, "coveragePercent": 0 },
|
||||||
|
"gaps": [
|
||||||
|
{ "topic": "...", "severity": "high/medium/low", "appearsInSources": "...", "courseCoverage": "..." }
|
||||||
|
],
|
||||||
|
"lessonQuality": [
|
||||||
|
{ "topicTitle": "...", "score": 0-100, "notes": "..." }
|
||||||
|
],
|
||||||
|
"recommendations": ["..."]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const evaluatorModel = (config as any).openrouterEvaluatorModel;
|
||||||
|
|
||||||
|
const result = await askAI(
|
||||||
|
[{ role: "user", content: prompt }],
|
||||||
|
{ maxRetries: 2, maxTokens: 4000, ...(evaluatorModel ? { model: evaluatorModel } : {}) }
|
||||||
|
);
|
||||||
|
const report = parseJSON<{ overallScore: number }>(result.text);
|
||||||
|
|
||||||
|
await db.update(courses)
|
||||||
|
.set({ auditReport: result.text, auditScore: report.overallScore ?? null })
|
||||||
|
.where(eq(courses.id, courseId));
|
||||||
|
|
||||||
|
console.log(`[audit:${courseId.slice(0, 8)}] ✓ audit complete — score: ${report.overallScore ?? "?"}/100`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[audit:${courseId.slice(0, 8)}] ✗ audit failed: ${err?.message ?? err}`);
|
||||||
|
await db.update(courses)
|
||||||
|
.set({ auditReport: null })
|
||||||
|
.where(eq(courses.id, courseId));
|
||||||
|
}
|
||||||
|
}
|
||||||
50
server/utils/detectUploadType.ts
Normal file
50
server/utils/detectUploadType.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { askAI } from "./openrouter";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
|
type UploadType = "slides" | "past_paper" | "lab_worksheet";
|
||||||
|
|
||||||
|
const PAST_PAPER_RE = /exam|past[\s_-]?paper|specimen|mock|resit|20(1[9]|2[0-6])/i;
|
||||||
|
const LAB_RE = /lab|worksheet|practical|experiment|\bprac\b/i;
|
||||||
|
const SLIDES_RE = /lecture|slides|\blec\b|week\d|topic\d|chapter/i;
|
||||||
|
|
||||||
|
export async function detectUploadType(filename: string, extractedText: string): Promise<UploadType> {
|
||||||
|
const name = filename.toLowerCase();
|
||||||
|
|
||||||
|
// layer 1 — filename heuristics
|
||||||
|
if (PAST_PAPER_RE.test(name)) return "past_paper";
|
||||||
|
if (LAB_RE.test(name)) return "lab_worksheet";
|
||||||
|
if (SLIDES_RE.test(name)) return "slides";
|
||||||
|
|
||||||
|
// layer 2 — AI classification
|
||||||
|
const opening = extractedText.slice(0, 1500);
|
||||||
|
|
||||||
|
const prompt = `You are classifying a university document. Based on the filename and the opening text, classify it as exactly one of: past_paper, lab_worksheet, or slides.
|
||||||
|
|
||||||
|
past_paper: an exam or test with questions students must answer under exam conditions
|
||||||
|
lab_worksheet: a practical worksheet with experiments, procedures, or guided tasks
|
||||||
|
slides: lecture slides or course notes presenting theory and concepts
|
||||||
|
|
||||||
|
Filename: ${filename}
|
||||||
|
Opening text: ${opening}
|
||||||
|
|
||||||
|
Respond with only one of: past_paper, lab_worksheet, slides`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const classificationModel = (config as any).openrouterClassificationModel ?? "anthropic/claude-haiku-4-5";
|
||||||
|
|
||||||
|
const { text } = await askAI(
|
||||||
|
[{ role: "user", content: prompt }],
|
||||||
|
{ model: classificationModel, temperature: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = text.trim().toLowerCase() as UploadType;
|
||||||
|
if (result === "past_paper" || result === "lab_worksheet" || result === "slides") {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to default
|
||||||
|
}
|
||||||
|
|
||||||
|
return "slides";
|
||||||
|
}
|
||||||
732
server/utils/generateCourse.ts
Normal file
732
server/utils/generateCourse.ts
Normal file
|
|
@ -0,0 +1,732 @@
|
||||||
|
import { db } from "../db/index";
|
||||||
|
import { courses, uploads, topics, lessons, quizQuestions } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { askAI } from "./openrouter";
|
||||||
|
import { auditCourse } from "./auditCourse";
|
||||||
|
import { generateStepTTS, generateQuestionTTS, generateOptionTTS, generateTTSToPath } from "./generateTTS";
|
||||||
|
|
||||||
|
type Stage = "parsing_pdfs" | "analysing_sources" | "building_curriculum" | "finalising" | "ready" | "error";
|
||||||
|
|
||||||
|
interface CourseContext {
|
||||||
|
courseTitle: string;
|
||||||
|
subject: string;
|
||||||
|
topicsInOrder: { order: number; title: string; description: string }[];
|
||||||
|
completedLessons: {
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
keyConcepts: string[];
|
||||||
|
analogiesUsed: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(courseId: string, msg: string) {
|
||||||
|
const short = courseId.slice(0, 8);
|
||||||
|
console.log(`[revisione:${short}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStage(courseId: string, stage: Stage) {
|
||||||
|
await db.update(courses).set({ stage }).where(eq(courses.id, courseId));
|
||||||
|
log(courseId, `stage → ${stage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSON<T>(raw: string): T {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
const cleaned = raw.replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returns mutated steps array with audioPath/audioChunks embedded, and total audio cost
|
||||||
|
async function generateLessonAudio(
|
||||||
|
steps: any[],
|
||||||
|
lessonId: string,
|
||||||
|
courseId: string
|
||||||
|
): Promise<{ steps: any[]; cost: number }> {
|
||||||
|
let cost = 0;
|
||||||
|
|
||||||
|
for (let si = 0; si < steps.length; si++) {
|
||||||
|
const step = steps[si];
|
||||||
|
|
||||||
|
if (step.type === "concept" || step.type === "example") {
|
||||||
|
const text = [step.body, step.callout].filter(Boolean).join(" ");
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
const result = await generateStepTTS(text, lessonId, si);
|
||||||
|
if (result) {
|
||||||
|
step.audioPath = result.audioPath;
|
||||||
|
step.audioChunks = result.audioChunks;
|
||||||
|
cost += result.cost;
|
||||||
|
}
|
||||||
|
} else if (step.type === "summary") {
|
||||||
|
const text = Array.isArray(step.bullets) ? step.bullets.join(". ") : "";
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
const result = await generateStepTTS(text, lessonId, si);
|
||||||
|
if (result) {
|
||||||
|
step.audioPath = result.audioPath;
|
||||||
|
step.audioChunks = result.audioChunks;
|
||||||
|
cost += result.cost;
|
||||||
|
}
|
||||||
|
} else if (step.type === "question") {
|
||||||
|
// question narration
|
||||||
|
if (step.body?.trim()) {
|
||||||
|
const qResult = await generateQuestionTTS(step.body, lessonId, si);
|
||||||
|
if (qResult) {
|
||||||
|
step.questionAudioPath = qResult.audioPath;
|
||||||
|
step.questionAudioChunks = qResult.audioChunks;
|
||||||
|
cost += qResult.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// per-option audio
|
||||||
|
if (Array.isArray(step.options)) {
|
||||||
|
step.optionAudioPaths = [];
|
||||||
|
for (let oi = 0; oi < step.options.length; oi++) {
|
||||||
|
const optText = step.options[oi];
|
||||||
|
if (optText?.trim()) {
|
||||||
|
const oResult = await generateOptionTTS(optText, lessonId, si, oi);
|
||||||
|
if (oResult) {
|
||||||
|
step.optionAudioPaths[oi] = oResult.audioPath;
|
||||||
|
cost += oResult.cost;
|
||||||
|
} else {
|
||||||
|
step.optionAudioPaths[oi] = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
step.optionAudioPaths[oi] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, ` step ${si} (${step.type}) TTS done`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { steps, cost };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCourseInBackground(courseId: string) {
|
||||||
|
try {
|
||||||
|
const course = await db.query.courses.findFirst({ where: eq(courses.id, courseId) });
|
||||||
|
if (!course) throw new Error(`Course ${courseId} not found`);
|
||||||
|
|
||||||
|
const costs = { ai: 0, audio: 0 };
|
||||||
|
|
||||||
|
log(courseId, `starting generation for "${course.title}"`);
|
||||||
|
|
||||||
|
// ── STEP 1 — load uploads ───────────────────────────────────────────────
|
||||||
|
await setStage(courseId, "parsing_pdfs");
|
||||||
|
|
||||||
|
const uploadRows = await db.query.uploads.findMany({
|
||||||
|
where: eq(uploads.courseId, courseId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadRows.length === 0) throw new Error("No uploads found for this course");
|
||||||
|
|
||||||
|
log(courseId, `found ${uploadRows.length} upload(s)`);
|
||||||
|
|
||||||
|
const primaryParts: string[] = [];
|
||||||
|
const secondaryParts: string[] = [];
|
||||||
|
|
||||||
|
for (const upload of uploadRows) {
|
||||||
|
if (!upload.extractedText) {
|
||||||
|
log(courseId, ` skipping "${upload.filename}" — no extracted text`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const snippet = `--- ${upload.filename} ---\n${upload.extractedText}`;
|
||||||
|
log(courseId, ` loaded "${upload.filename}" (${upload.type}, ${upload.extractedText.length} chars)`);
|
||||||
|
|
||||||
|
if (upload.type === "past_paper" || upload.type === "lab_worksheet") {
|
||||||
|
primaryParts.push(snippet);
|
||||||
|
} else {
|
||||||
|
secondaryParts.push(snippet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, `source split — primary: ${primaryParts.length}, secondary: ${secondaryParts.length}`);
|
||||||
|
|
||||||
|
// ── STEP 1b — infer title, subject, and topic count ────────────────────
|
||||||
|
await setStage(courseId, "analysing_sources");
|
||||||
|
|
||||||
|
const allExtracted = [
|
||||||
|
...primaryParts.join("\n\n"),
|
||||||
|
...secondaryParts.join("\n\n"),
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
|
log(courseId, "inferring course title and subject from documents…");
|
||||||
|
|
||||||
|
const inferenceResult = await askAI([{
|
||||||
|
role: "user",
|
||||||
|
content: `You are analysing a set of university course documents including lecture slides, past exam papers, and lab worksheets.
|
||||||
|
|
||||||
|
Based on the content, return a JSON object with:
|
||||||
|
- "title": a concise course name (e.g. "Computer Vision", "Thermodynamics", "Microeconomics")
|
||||||
|
- "subject": the broader academic discipline (e.g. "Computer Science", "Physics", "Economics")
|
||||||
|
- "organisation": the university or institution these materials are from (e.g. "University of Essex", "Imperial College London"). Infer this from headers, exam paper footers, logos described in text, or module codes. Return null if you genuinely cannot determine it.
|
||||||
|
|
||||||
|
Return only valid JSON, no markdown.
|
||||||
|
|
||||||
|
DOCUMENTS:
|
||||||
|
${allExtracted}`,
|
||||||
|
}]);
|
||||||
|
costs.ai += inferenceResult.cost;
|
||||||
|
|
||||||
|
let inferredMeta: { title: string; subject: string; organisation?: string | null } = {
|
||||||
|
title: course.title,
|
||||||
|
subject: course.subject,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
inferredMeta = parseJSON(inferenceResult.text);
|
||||||
|
} catch {
|
||||||
|
log(courseId, "inference parse failed, using defaults");
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, `inferred → title: "${inferredMeta.title}", subject: "${inferredMeta.subject}"`);
|
||||||
|
|
||||||
|
await db.update(courses)
|
||||||
|
.set({
|
||||||
|
title: inferredMeta.title,
|
||||||
|
subject: inferredMeta.subject,
|
||||||
|
...(inferredMeta.organisation != null ? { organisation: inferredMeta.organisation } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(courses.id, courseId));
|
||||||
|
|
||||||
|
// ── STEP 2 — generate topic list (skip if topics already saved) ─────────
|
||||||
|
|
||||||
|
let savedTopics = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, courseId),
|
||||||
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (savedTopics.length > 0) {
|
||||||
|
log(courseId, `resuming — found ${savedTopics.length} existing topic(s), skipping curriculum generation`);
|
||||||
|
} else {
|
||||||
|
const primaryText = primaryParts.join("\n\n");
|
||||||
|
const secondaryText = secondaryParts.join("\n\n");
|
||||||
|
|
||||||
|
const availableFilesBlock = uploadRows
|
||||||
|
.map((u) => `- ${u.filename} (${u.type})`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const curriculumPrompt = `You are designing a complete revision course from scratch.
|
||||||
|
|
||||||
|
Your only measure of success is this: a student who completes every lesson in this course must be able to:
|
||||||
|
- Answer every question in every past paper provided, including calculation questions, pseudocode questions, diagram questions, and scenario questions
|
||||||
|
- Perform every procedure, method, and algorithm named in the source material — not just describe them, but actually do them
|
||||||
|
- Fully understand every concept present in the source material, with no gaps
|
||||||
|
|
||||||
|
This is a non-negotiable standard. Do not summarise. Do not compress topics together if doing so would leave any gap in the student's ability to answer a past paper question. If meeting this standard requires 50 topics, generate 50 topics. If it requires 8, generate 8. There is no limit in either direction.
|
||||||
|
|
||||||
|
BEFORE generating topics, do the following analysis mentally:
|
||||||
|
1. Read every past paper question carefully. For each question, ask: what does a student need to know and be able to DO to answer this? List every distinct skill, concept, calculation method, algorithm, and procedure required.
|
||||||
|
2. Read every lab worksheet. For each task, ask: what does a student need to know and be able to DO to complete this? Add any new skills, concepts, or procedures to the list.
|
||||||
|
3. Read the lecture slides. Add any concept or topic that appears in the slides but is not yet in the list.
|
||||||
|
4. Now organise the list into topics, ordered from simplest to most complex, such that each topic assumes only the knowledge of topics before it.
|
||||||
|
|
||||||
|
TOPIC REQUIREMENTS:
|
||||||
|
- Every distinct algorithm named in the source material must have at least one dedicated topic that teaches it to implementation level — the student must be able to apply it step by step, not just name it
|
||||||
|
- Every calculation that appears in a past paper must be covered in a topic that teaches the student to perform that exact type of calculation by hand, with worked examples matching the exam style
|
||||||
|
- Every procedure that appears in a lab worksheet must be covered in a topic that teaches the student to carry out that procedure
|
||||||
|
- If a past paper asks for pseudocode, the corresponding topic must teach the student to write that pseudocode
|
||||||
|
- Conceptual understanding alone is never sufficient. Every topic must result in a student who can DO something, not just know something
|
||||||
|
|
||||||
|
AVAILABLE SOURCE FILES (you must reference these exact filenames in relevantFiles):
|
||||||
|
${availableFilesBlock}
|
||||||
|
|
||||||
|
PRIMARY SOURCES (past papers + lab worksheets — these define what the student must be able to do):
|
||||||
|
${primaryText || "(none provided)"}
|
||||||
|
|
||||||
|
SECONDARY SOURCES (lecture slides — use for additional concepts and explanations):
|
||||||
|
${secondaryText || "(none provided)"}
|
||||||
|
|
||||||
|
Return only valid JSON — an array of topics with no markdown:
|
||||||
|
[{ "title": "...", "description": "...", "difficulty": 1-5, "order": 1, "relevantFiles": ["filename.pdf"] }]
|
||||||
|
|
||||||
|
relevantFiles must list only filenames from the AVAILABLE SOURCE FILES list that directly contain content for this topic. Include at minimum the files that have past paper questions or lab tasks this topic must prepare the student for.
|
||||||
|
|
||||||
|
The description must be specific about what the student will be able to DO after completing this topic, not just what it covers.`;
|
||||||
|
|
||||||
|
await setStage(courseId, "building_curriculum");
|
||||||
|
log(courseId, "calling OpenRouter for curriculum…");
|
||||||
|
const curriculumResult = await askAI([{ role: "user", content: curriculumPrompt }]);
|
||||||
|
costs.ai += curriculumResult.cost;
|
||||||
|
const curriculum = parseJSON<{ title: string; description: string; difficulty: number; relevantFiles?: string[] }[]>(curriculumResult.text);
|
||||||
|
|
||||||
|
if (!Array.isArray(curriculum) || curriculum.length === 0) {
|
||||||
|
throw new Error("AI returned an empty curriculum");
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, `curriculum received — ${curriculum.length} topics:`);
|
||||||
|
curriculum.forEach((t, i) => log(courseId, ` ${i + 1}. ${t.title} (difficulty ${t.difficulty})`));
|
||||||
|
|
||||||
|
await setStage(courseId, "finalising");
|
||||||
|
|
||||||
|
for (let i = 0; i < curriculum.length; i++) {
|
||||||
|
const t = curriculum[i];
|
||||||
|
await db.insert(topics).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
courseId,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
order: i,
|
||||||
|
difficulty: Math.min(5, Math.max(1, t.difficulty ?? 1)),
|
||||||
|
prerequisiteTopicIds: "[]",
|
||||||
|
relevantFiles: JSON.stringify(t.relevantFiles ?? []),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-fetch so we have the real DB rows with IDs
|
||||||
|
savedTopics = await db.query.topics.findMany({
|
||||||
|
where: eq(topics.courseId, courseId),
|
||||||
|
orderBy: (t, { asc }) => asc(t.order),
|
||||||
|
});
|
||||||
|
|
||||||
|
log(courseId, `saved ${savedTopics.length} topics to DB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setStage(courseId, "finalising");
|
||||||
|
|
||||||
|
// ── STEP 3 — build course context from what's already done ─────────────
|
||||||
|
const courseSubject = inferredMeta?.subject ?? course.subject;
|
||||||
|
const topicListText = savedTopics.map((t) => `${t.order + 1}. ${t.title}`).join("\n");
|
||||||
|
|
||||||
|
const courseContext: CourseContext = {
|
||||||
|
courseTitle: course.title,
|
||||||
|
subject: course.subject,
|
||||||
|
topicsInOrder: savedTopics.map((t) => ({
|
||||||
|
order: t.order,
|
||||||
|
title: t.title,
|
||||||
|
description: t.description,
|
||||||
|
})),
|
||||||
|
completedLessons: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── STEP 4 — generate lessons + quizzes, skipping completed ones ────────
|
||||||
|
for (const topic of savedTopics) {
|
||||||
|
const i = topic.order;
|
||||||
|
const isFirst = i === 0;
|
||||||
|
|
||||||
|
// check what's already generated for this topic
|
||||||
|
const existingLesson = await db.query.lessons.findFirst({
|
||||||
|
where: eq(lessons.topicId, topic.id),
|
||||||
|
});
|
||||||
|
const existingQuiz = await db.query.quizQuestions.findMany({
|
||||||
|
where: eq(quizQuestions.topicId, topic.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLesson && existingQuiz.length > 0) {
|
||||||
|
// fully done — just add to context and move on
|
||||||
|
const content = JSON.parse(existingLesson.content) as {
|
||||||
|
keyConcepts?: string[];
|
||||||
|
analogiesUsed?: string[];
|
||||||
|
};
|
||||||
|
courseContext.completedLessons.push({
|
||||||
|
order: i,
|
||||||
|
title: topic.title,
|
||||||
|
keyConcepts: content.keyConcepts ?? [],
|
||||||
|
analogiesUsed: content.analogiesUsed ?? [],
|
||||||
|
});
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] "${topic.title}" already complete — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// build prior knowledge block from what's completed so far
|
||||||
|
let priorKnowledge: string;
|
||||||
|
if (courseContext.completedLessons.length === 0) {
|
||||||
|
priorKnowledge = "This is the very first lesson — assume zero prior knowledge of the subject.";
|
||||||
|
} else {
|
||||||
|
priorKnowledge = courseContext.completedLessons
|
||||||
|
.map((l) => `- ${l.title}: covered concepts [${l.keyConcepts.join(", ")}] using analogies [${l.analogiesUsed.join(", ")}]`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// build source context for this topic from its relevantFiles, falling back to all primary sources
|
||||||
|
const topicRelevantFiles: string[] = (() => {
|
||||||
|
try { return JSON.parse(topic.relevantFiles ?? "[]"); } catch { return []; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const relevantUploads = topicRelevantFiles.length > 0
|
||||||
|
? uploadRows.filter((u) => topicRelevantFiles.includes(u.filename) && u.extractedText)
|
||||||
|
: uploadRows.filter((u) => (u.type === "past_paper" || u.type === "lab_worksheet") && u.extractedText);
|
||||||
|
|
||||||
|
const primaryTextForLesson = relevantUploads
|
||||||
|
.map((u) => `--- ${u.filename} ---\n${u.extractedText}`)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const secondaryTextForLesson = topicRelevantFiles.length > 0
|
||||||
|
? uploadRows
|
||||||
|
.filter((u) => topicRelevantFiles.includes(u.filename) && u.extractedText && u.type === "slides")
|
||||||
|
.map((u) => `--- ${u.filename} ---\n${u.extractedText}`)
|
||||||
|
.join("\n\n")
|
||||||
|
: secondaryParts.join("\n\n");
|
||||||
|
|
||||||
|
// generate lesson if missing
|
||||||
|
let lessonContent: { keyConcepts: string[]; analogiesUsed: string[]; steps: any[] };
|
||||||
|
|
||||||
|
if (existingLesson) {
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] lesson already exists for "${topic.title}", generating quiz only`);
|
||||||
|
lessonContent = JSON.parse(existingLesson.content);
|
||||||
|
} else {
|
||||||
|
const lessonPrompt = `You are writing a lesson for a course on ${courseSubject}.
|
||||||
|
|
||||||
|
YOUR ONLY MEASURE OF SUCCESS:
|
||||||
|
A student who completes this lesson must be able to answer any past paper or lab question that requires knowledge of this topic. That means they must be able to DO the thing, not just understand it. If this topic involves a calculation, they must be able to perform it. If it involves an algorithm, they must be able to apply it step by step. If it involves pseudocode, they must be able to write it. Conceptual understanding alone is never the goal — competence is the goal.
|
||||||
|
|
||||||
|
WHAT THE STUDENT KNOWS:
|
||||||
|
- Basic English, everyday maths (arithmetic, simple algebra, fractions, proportions), and general school-level science
|
||||||
|
- Nothing domain-specific about ${courseSubject} unless it appears below
|
||||||
|
- Everything explicitly taught in previous lessons:
|
||||||
|
|
||||||
|
${isFirst ? `This is the very first lesson. The student knows nothing about this subject yet. Start from absolute zero.` : courseContext.completedLessons.map((l) => `Lesson ${l.order + 1} — ${l.title}: ${l.keyConcepts.join(", ")}`).join("\n")}
|
||||||
|
|
||||||
|
DO NOT use any technical term that does not appear in the above list or is not introduced and explained in the current lesson. This is a hard rule. It applies everywhere — questions, options, callouts, summaries.
|
||||||
|
|
||||||
|
COURSE STRUCTURE:
|
||||||
|
This course has ${savedTopics.length} lessons in this order:
|
||||||
|
${topicListText}
|
||||||
|
|
||||||
|
YOUR CURRENT LESSON: ${topic.title} — ${topic.description}
|
||||||
|
|
||||||
|
SOURCE MATERIAL:
|
||||||
|
The following are the actual source files relevant to this topic — past papers, lab worksheets, and lecture slides. Your lesson must prepare the student to answer every question in these files that relates to this topic:
|
||||||
|
${primaryTextForLesson || "(no primary sources provided)"}
|
||||||
|
${secondaryTextForLesson ? `\nLECTURE SLIDES:\n${secondaryTextForLesson}` : ""}
|
||||||
|
|
||||||
|
TEACHING PHILOSOPHY:
|
||||||
|
|
||||||
|
OPENING:
|
||||||
|
- The very first sentence must make the student curious, smile, or feel something. Never open with a definition, a recap, or a statement of what they are about to learn.
|
||||||
|
- Open with the analogy or human moment immediately.
|
||||||
|
|
||||||
|
ANALOGIES:
|
||||||
|
- Every concept step must open with a concrete real-world analogy before any technical language.
|
||||||
|
- The analogy comes first. The technical idea is revealed through it.
|
||||||
|
- Never repeat an analogy used in a previous lesson.
|
||||||
|
- Analogies must connect to everyday life, not the subject domain.
|
||||||
|
|
||||||
|
BUILDING ON PRIOR KNOWLEDGE:
|
||||||
|
- Freely use terms and concepts from completed lessons without re-explaining them.
|
||||||
|
- Reference prior concepts as bridges: "remember how X worked — this is that same idea applied to Y."
|
||||||
|
|
||||||
|
MATHEMATICS, ALGORITHMS, AND PROCEDURES:
|
||||||
|
- Before any formula, write one sentence in plain English saying what the relationship means intuitively. Vary the phrasing — never use "In plain terms" more than once per lesson.
|
||||||
|
- After introducing a formula or algorithm, immediately show a complete worked example that matches the style of the past paper questions for this topic.
|
||||||
|
- Never show more than one formula per step.
|
||||||
|
- Never introduce a variable without saying in plain English what it represents.
|
||||||
|
- If this topic requires the student to perform a procedure step by step, there must be at least one example step that walks through the complete procedure on a concrete example, showing every step explicitly.
|
||||||
|
- If past papers ask for pseudocode on this topic, there must be a concept or example step that shows the pseudocode and explains each line.
|
||||||
|
|
||||||
|
QUESTIONS:
|
||||||
|
- Every question must be answerable using only what has been explicitly taught in this lesson up to that point, plus concepts from completed lessons.
|
||||||
|
- Questions immediately after the first concept step must be the simplest — their only job is to confirm the student understood the core analogy.
|
||||||
|
- Never ask a student to perform a full calculation in a single question. Use only:
|
||||||
|
(a) PARTIAL WORKING: Show known values and partial working, ask the student to identify the correct next step.
|
||||||
|
(b) INTERPRET THE RESULT: Give the numerical answer, ask what it means in context.
|
||||||
|
(c) SPOT THE ERROR: Show a worked example with a mistake, ask the student to identify what went wrong and why.
|
||||||
|
- Answer options must be short and scannable — under 15 words each.
|
||||||
|
- Wrong answer options must represent genuine conceptual misconceptions, not arithmetic errors.
|
||||||
|
- Never use a technical term in any answer option that has not already been taught.
|
||||||
|
|
||||||
|
RHYTHM AND PACING:
|
||||||
|
- Never place two question steps consecutively without a concept or example step between them.
|
||||||
|
- The lesson must not become more question-heavy in the second half.
|
||||||
|
- A concept or example step must always appear after the final question and before the summary.
|
||||||
|
- Every concept and example step body: 3-4 sentences maximum.
|
||||||
|
- The lesson should feel like it has a rhythm: teach, check, teach, check, show, check, land.
|
||||||
|
|
||||||
|
TONE:
|
||||||
|
- Warm, clear, occasionally witty. The most engaging teacher the student has ever had.
|
||||||
|
- Never dry, never robotic, never formal for formality's sake.
|
||||||
|
- Short sentences. Active voice. Concrete over abstract.
|
||||||
|
|
||||||
|
SUMMARY:
|
||||||
|
- Bullet count must exactly match keyConcepts count.
|
||||||
|
- Each bullet must be a complete thought that makes sense without reading the lesson.
|
||||||
|
- The final bullet must gesture forward — what will this knowledge unlock?
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
Return only valid JSON with no markdown fences:
|
||||||
|
{
|
||||||
|
"keyConcepts": ["..."],
|
||||||
|
"analogiesUsed": ["..."],
|
||||||
|
"steps": [
|
||||||
|
{ "type": "concept", "title": "...", "body": "..." },
|
||||||
|
{ "type": "question", "body": "...", "options": ["...", "...", "...", "..."], "answer": "full correct answer text", "explanation": "..." },
|
||||||
|
{ "type": "example", "title": "...", "body": "...", "callout": "..." },
|
||||||
|
{ "type": "question", "body": "...", "options": ["...", "...", "...", "..."], "answer": "full correct answer text", "explanation": "..." },
|
||||||
|
{ "type": "summary", "title": "Key Takeaways", "bullets": ["...", "..."] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Steps must interleave concept/example and question types — never two questions or two concepts in a row. Minimum 6 steps, maximum 16. Use more steps when the topic requires it to achieve full competence.`;
|
||||||
|
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating lesson for "${topic.title}"…`);
|
||||||
|
const lessonResult = await askAI([{ role: "user", content: lessonPrompt }]);
|
||||||
|
costs.ai += lessonResult.cost;
|
||||||
|
lessonContent = parseJSON(lessonResult.text);
|
||||||
|
|
||||||
|
const lessonId = randomUUID();
|
||||||
|
|
||||||
|
const ttsProvider = (useRuntimeConfig().ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
|
||||||
|
|
||||||
|
await db.insert(lessons).values({
|
||||||
|
id: lessonId,
|
||||||
|
topicId: topic.id,
|
||||||
|
content: JSON.stringify(lessonContent),
|
||||||
|
ttsProvider,
|
||||||
|
});
|
||||||
|
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] lesson saved — ${lessonContent.steps?.length ?? 0} steps, concepts: [${(lessonContent.keyConcepts ?? []).join(", ")}]`);
|
||||||
|
|
||||||
|
// generate per-step TTS — non-fatal, embeds audio into step objects
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating per-step TTS for lesson ${lessonId}…`);
|
||||||
|
try {
|
||||||
|
const { steps: stepsWithAudio, cost: audioCost } = await generateLessonAudio(
|
||||||
|
lessonContent.steps as any[],
|
||||||
|
lessonId,
|
||||||
|
courseId
|
||||||
|
);
|
||||||
|
lessonContent.steps = stepsWithAudio;
|
||||||
|
costs.audio += audioCost;
|
||||||
|
|
||||||
|
// update DB with embedded audio
|
||||||
|
await db.update(lessons)
|
||||||
|
.set({ content: JSON.stringify(lessonContent) })
|
||||||
|
.where(eq(lessons.id, lessonId));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[revisione] TTS generation failed for lesson ${lessonId}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── generate branches for each question step ──────────────────────
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating branches for lesson ${lessonId}…`);
|
||||||
|
let branchesChanged = false;
|
||||||
|
|
||||||
|
for (let si = 0; si < lessonContent.steps.length; si++) {
|
||||||
|
const step = lessonContent.steps[si] as any;
|
||||||
|
if (step.type !== "question") continue;
|
||||||
|
|
||||||
|
const OPTION_LABELS = ["A", "B", "C", "D"];
|
||||||
|
const optionsText = (step.options as string[])
|
||||||
|
.map((o: string, idx: number) => `${OPTION_LABELS[idx]}: ${o}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const priorBlock = courseContext.completedLessons.length
|
||||||
|
? courseContext.completedLessons
|
||||||
|
.map((l) => `- ${l.title}: [${l.keyConcepts.join(", ")}]`)
|
||||||
|
.join("\n")
|
||||||
|
: "This is the first lesson.";
|
||||||
|
|
||||||
|
const branchPrompt = `You are writing remediation branches for a question in a lesson about ${courseSubject}.
|
||||||
|
|
||||||
|
SOURCE MATERIAL FOR THIS TOPIC:
|
||||||
|
${primaryTextForLesson || "(none)"}
|
||||||
|
|
||||||
|
THE QUESTION:
|
||||||
|
${step.body}
|
||||||
|
|
||||||
|
THE OPTIONS:
|
||||||
|
${optionsText}
|
||||||
|
|
||||||
|
THE CORRECT ANSWER: ${step.answer}
|
||||||
|
|
||||||
|
WHAT THE STUDENT KNOWS SO FAR:
|
||||||
|
${priorBlock}
|
||||||
|
|
||||||
|
YOUR TASK:
|
||||||
|
For each wrong answer option, write a branch that:
|
||||||
|
1. Opens by acknowledging the specific thinking behind that wrong answer — not generically ("that's wrong") but specifically ("It makes sense to think that, because X — but here is where that reasoning breaks down...")
|
||||||
|
2. Contains 1-2 short teaching steps that directly address the specific misconception behind that wrong answer
|
||||||
|
3. Ends with a confirming question that tests whether the student now understands the correct concept
|
||||||
|
4. Includes a warm, honest hard-stop message for if they fail the confirming question too
|
||||||
|
|
||||||
|
BRANCH TEACHING RULES:
|
||||||
|
- Each branch must feel like a patient teacher who has seen this exact mistake before and knows exactly how to fix it
|
||||||
|
- The opening must name the misconception specifically — never say "incorrect" or "wrong" — say "here is what that answer is actually describing..." or "that reasoning would be right if... but in this case..."
|
||||||
|
- Steps must be 2-3 sentences maximum — these are micro-lessons, not full explanations
|
||||||
|
- The confirming question must be simpler than the original question — it tests the core concept only
|
||||||
|
- The confirming question options must be short and scannable — under 12 words each
|
||||||
|
- The hard stop message must be warm, not discouraging — acknowledge that some concepts need more time, tell them specifically what to look up, and invite them back
|
||||||
|
- Never use technical terms that haven't been taught yet
|
||||||
|
|
||||||
|
Return only valid JSON, no markdown. Structure:
|
||||||
|
{
|
||||||
|
"branches": {
|
||||||
|
"{exact text of wrong option}": {
|
||||||
|
"steps": [
|
||||||
|
{ "type": "concept", "title": "...", "body": "..." }
|
||||||
|
],
|
||||||
|
"confirmQuestion": {
|
||||||
|
"body": "...",
|
||||||
|
"options": ["...", "...", "...", "..."],
|
||||||
|
"answer": "full correct answer text",
|
||||||
|
"explanation": "..."
|
||||||
|
},
|
||||||
|
"hardStop": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Only generate branches for the 3 wrong options. Do not generate a branch for the correct answer.`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const branchResult = await askAI([{ role: "user", content: branchPrompt }]);
|
||||||
|
costs.ai += branchResult.cost;
|
||||||
|
|
||||||
|
const parsed = parseJSON<{ branches: Record<string, any> }>(branchResult.text);
|
||||||
|
step.branches = parsed.branches ?? {};
|
||||||
|
branchesChanged = true;
|
||||||
|
|
||||||
|
log(courseId, ` step ${si} branches generated — ${Object.keys(step.branches).length} wrong options`);
|
||||||
|
|
||||||
|
// TTS for each branch
|
||||||
|
const wrongOptions = (step.options as string[]).filter((o: string) => o !== step.answer);
|
||||||
|
for (let bi = 0; bi < wrongOptions.length; bi++) {
|
||||||
|
const wrongOpt = wrongOptions[bi];
|
||||||
|
const branch = step.branches[wrongOpt];
|
||||||
|
if (!branch) continue;
|
||||||
|
|
||||||
|
// branch concept steps
|
||||||
|
for (let bsi = 0; bsi < (branch.steps ?? []).length; bsi++) {
|
||||||
|
const bStep = branch.steps[bsi];
|
||||||
|
const text = [bStep.body, bStep.callout].filter(Boolean).join(" ");
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
const r = await generateTTSToPath(text, lessonId, `branch_${si}_${bi}_step_${bsi}.mp3`);
|
||||||
|
if (r) {
|
||||||
|
bStep.audioPath = r.audioPath;
|
||||||
|
bStep.audioChunks = r.audioChunks;
|
||||||
|
costs.audio += r.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm question narration
|
||||||
|
if (branch.confirmQuestion?.body?.trim()) {
|
||||||
|
const r = await generateTTSToPath(branch.confirmQuestion.body, lessonId, `branch_${si}_${bi}_confirm_q.mp3`);
|
||||||
|
if (r) {
|
||||||
|
branch.confirmQuestion.questionAudioPath = r.audioPath;
|
||||||
|
branch.confirmQuestion.questionAudioChunks = r.audioChunks;
|
||||||
|
costs.audio += r.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm question options
|
||||||
|
if (Array.isArray(branch.confirmQuestion?.options)) {
|
||||||
|
branch.confirmQuestion.optionAudioPaths = [];
|
||||||
|
for (let oi = 0; oi < branch.confirmQuestion.options.length; oi++) {
|
||||||
|
const optText = branch.confirmQuestion.options[oi];
|
||||||
|
if (optText?.trim()) {
|
||||||
|
const r = await generateTTSToPath(optText, lessonId, `branch_${si}_${bi}_confirm_opt_${oi}.mp3`);
|
||||||
|
branch.confirmQuestion.optionAudioPaths[oi] = r ? r.audioPath : null;
|
||||||
|
if (r) costs.audio += r.cost;
|
||||||
|
} else {
|
||||||
|
branch.confirmQuestion.optionAudioPaths[oi] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hard stop narration
|
||||||
|
if (branch.hardStop?.trim()) {
|
||||||
|
const r = await generateTTSToPath(branch.hardStop, lessonId, `branch_${si}_${bi}_hardstop.mp3`);
|
||||||
|
if (r) {
|
||||||
|
branch.hardStopAudioPath = r.audioPath;
|
||||||
|
costs.audio += r.cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, ` step ${si} branch ${bi} TTS done`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[revisione] branch gen failed for step ${si} in lesson ${lessonId}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchesChanged) {
|
||||||
|
await db.update(lessons)
|
||||||
|
.set({ content: JSON.stringify(lessonContent) })
|
||||||
|
.where(eq(lessons.id, lessonId));
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] branches + audio saved to DB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
courseContext.completedLessons.push({
|
||||||
|
order: i,
|
||||||
|
title: topic.title,
|
||||||
|
keyConcepts: lessonContent.keyConcepts ?? [],
|
||||||
|
analogiesUsed: lessonContent.analogiesUsed ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// generate quiz if missing
|
||||||
|
if (existingQuiz.length > 0) {
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] quiz already exists for "${topic.title}" — skipping`);
|
||||||
|
} else {
|
||||||
|
const quizPrompt = `You are an exam question writer for a university course on ${courseSubject}.
|
||||||
|
|
||||||
|
COURSE CONTEXT:
|
||||||
|
The student has just completed a lesson on "${topic.title}" which covered: ${(lessonContent.keyConcepts ?? []).join(", ")}.
|
||||||
|
This is topic ${i + 1} of ${savedTopics.length} — difficulty level: ${topic.difficulty}/5.
|
||||||
|
|
||||||
|
SOURCE MATERIAL FOR THIS TOPIC (use these to match question style, difficulty, and content exactly):
|
||||||
|
${primaryTextForLesson || "(none provided)"}
|
||||||
|
|
||||||
|
Generate 4 quiz questions for this topic. Mix MCQ and short_answer types. For MCQ, provide 4 options labeled A, B, C, D.
|
||||||
|
Match the difficulty level — topic 1 should be very approachable, later topics can be more demanding.
|
||||||
|
|
||||||
|
Respond with ONLY valid JSON array, no markdown fences:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"question": "...",
|
||||||
|
"type": "mcq",
|
||||||
|
"options": ["A. ...", "B. ...", "C. ...", "D. ..."],
|
||||||
|
"answer": "A",
|
||||||
|
"explanation": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "...",
|
||||||
|
"type": "short_answer",
|
||||||
|
"options": null,
|
||||||
|
"answer": "...",
|
||||||
|
"explanation": "..."
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] generating quiz for "${topic.title}"…`);
|
||||||
|
const quizResult = await askAI([{ role: "user", content: quizPrompt }]);
|
||||||
|
costs.ai += quizResult.cost;
|
||||||
|
const questions = parseJSON<{
|
||||||
|
question: string;
|
||||||
|
type: string;
|
||||||
|
options: string[] | null;
|
||||||
|
answer: string;
|
||||||
|
explanation: string;
|
||||||
|
}[]>(quizResult.text);
|
||||||
|
|
||||||
|
for (const q of questions) {
|
||||||
|
await db.insert(quizQuestions).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
topicId: topic.id,
|
||||||
|
question: q.question,
|
||||||
|
type: q.type as "mcq" | "short_answer" | "worked",
|
||||||
|
options: q.options ? JSON.stringify(q.options) : null,
|
||||||
|
answer: q.answer,
|
||||||
|
explanation: q.explanation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(courseId, ` [${i + 1}/${savedTopics.length}] quiz saved — ${questions.length} questions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STEP 5 — mark ready ─────────────────────────────────────────────────
|
||||||
|
await db.update(courses)
|
||||||
|
.set({ status: "ready", stage: "ready", costAI: costs.ai, costAudio: costs.audio })
|
||||||
|
.where(eq(courses.id, courseId));
|
||||||
|
log(courseId, `✓ generation complete — ${savedTopics.length} topics | cost: AI $${costs.ai.toFixed(4)}, audio $${costs.audio.toFixed(4)}`);
|
||||||
|
|
||||||
|
// ── STEP 6 — post-generation audit (non-blocking) ───────────────────────
|
||||||
|
await auditCourse(courseId);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[revisione:${courseId.slice(0, 8)}] ✗ generation failed: ${err?.message ?? err}`);
|
||||||
|
await db.update(courses).set({ status: "error", stage: "error" }).where(eq(courses.id, courseId));
|
||||||
|
}
|
||||||
|
}
|
||||||
84
server/utils/generateLabels.ts
Normal file
84
server/utils/generateLabels.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { resolve } from "path";
|
||||||
|
import { generateClip, fileExists } from "./generateTTS";
|
||||||
|
|
||||||
|
const LABEL_CLIPS: Record<string, string> = {
|
||||||
|
A_1: "[calm] A,",
|
||||||
|
A_2: "[calm] A,",
|
||||||
|
A_3: "[thoughtful] A,",
|
||||||
|
A_4: "[measured] A,",
|
||||||
|
|
||||||
|
B_1: "[calm] B,",
|
||||||
|
B_2: "[thoughtful] B,",
|
||||||
|
B_3: "[calm] B,",
|
||||||
|
B_4: "[measured] B,",
|
||||||
|
|
||||||
|
C_1: "[thoughtful] C,",
|
||||||
|
C_2: "[calm] C,",
|
||||||
|
C_3: "[measured] C,",
|
||||||
|
C_4: "[calm] C,",
|
||||||
|
|
||||||
|
D_1: "[measured] D.",
|
||||||
|
D_2: "[calm] D.",
|
||||||
|
D_3: "[thoughtful] D.",
|
||||||
|
D_4: "[measured] D.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VOICE_SETTINGS = { stability: 0.3, similarity_boost: 0.75 };
|
||||||
|
|
||||||
|
const TRANSITION_TEXT = "[thoughtful] Let's look at this a different way...";
|
||||||
|
|
||||||
|
const CORRECT_CLIPS: Record<string, string> = {
|
||||||
|
correct_1: "Exactly right.",
|
||||||
|
correct_2: "That's it.",
|
||||||
|
correct_3: "Spot on.",
|
||||||
|
correct_4: "Perfect.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateLabels() {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiKey = config.elevenlabsApiKey;
|
||||||
|
const voiceId = config.elevenlabsVoiceId;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn("[labels] ELEVENLABS_API_KEY not set — skipping label generation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelsDir = resolve(process.cwd(), "public/audio/labels");
|
||||||
|
|
||||||
|
for (const [key, text] of Object.entries(LABEL_CLIPS)) {
|
||||||
|
const outPath = `${labelsDir}/${key}.mp3`;
|
||||||
|
|
||||||
|
if (await fileExists(outPath)) continue;
|
||||||
|
|
||||||
|
console.log(`[labels] generating ${key}.mp3…`);
|
||||||
|
await generateClip(text, outPath, apiKey, voiceId, {
|
||||||
|
model: "eleven_v3",
|
||||||
|
voice_settings: VOICE_SETTINGS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// branch transition clip
|
||||||
|
const transitionPath = resolve(process.cwd(), "public/audio/branch-transition.mp3");
|
||||||
|
if (!(await fileExists(transitionPath))) {
|
||||||
|
console.log("[labels] generating branch-transition.mp3…");
|
||||||
|
await generateClip(TRANSITION_TEXT, transitionPath, apiKey, voiceId, {
|
||||||
|
model: "eleven_v3",
|
||||||
|
voice_settings: VOICE_SETTINGS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// correct affirmation clips
|
||||||
|
const audioDir = resolve(process.cwd(), "public/audio");
|
||||||
|
for (const [key, text] of Object.entries(CORRECT_CLIPS)) {
|
||||||
|
const outPath = `${audioDir}/${key}.mp3`;
|
||||||
|
if (await fileExists(outPath)) continue;
|
||||||
|
console.log(`[labels] generating ${key}.mp3…`);
|
||||||
|
await generateClip(text, outPath, apiKey, voiceId, {
|
||||||
|
model: "eleven_v3",
|
||||||
|
voice_settings: { stability: 0.35, similarity_boost: 0.8 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[labels] all label clips ready");
|
||||||
|
}
|
||||||
329
server/utils/generateTTS.ts
Normal file
329
server/utils/generateTTS.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
import { mkdir, writeFile, access } from "fs/promises";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
export interface AudioChunk {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSResult {
|
||||||
|
audioPath: string;
|
||||||
|
audioChunks: AudioChunk[];
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function callElevenLabs(
|
||||||
|
text: string,
|
||||||
|
apiKey: string,
|
||||||
|
voiceId: string
|
||||||
|
): Promise<{ audio: Buffer; chunks: AudioChunk[]; cost: number } | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/with-timestamps`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"xi-api-key": apiKey,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
model_id: "eleven_turbo_v2_5",
|
||||||
|
output_format: "mp3_44100_128",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[tts] ElevenLabs error ${res.status}: ${errText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const charCost = parseInt(res.headers.get("character-cost") ?? "0", 10);
|
||||||
|
const cost = charCost * 0.0003;
|
||||||
|
|
||||||
|
const data = await res.json() as {
|
||||||
|
audio_base64: string;
|
||||||
|
alignment: {
|
||||||
|
characters: string[];
|
||||||
|
character_start_times_seconds: number[];
|
||||||
|
character_end_times_seconds: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const audio = Buffer.from(data.audio_base64, "base64");
|
||||||
|
const { characters, character_start_times_seconds: starts, character_end_times_seconds: ends } = data.alignment;
|
||||||
|
|
||||||
|
const words: { word: string; charStart: number; charEnd: number }[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
while (i < text.length && /\s/.test(text[i])) i++;
|
||||||
|
if (i >= text.length) break;
|
||||||
|
const wordStart = i;
|
||||||
|
while (i < text.length && !/\s/.test(text[i])) i++;
|
||||||
|
words.push({ word: text.slice(wordStart, i), charStart: wordStart, charEnd: i - 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeForChar(charIdx: number, side: "start" | "end"): number {
|
||||||
|
let accumulated = 0;
|
||||||
|
for (let ci = 0; ci < characters.length; ci++) {
|
||||||
|
const c = characters[ci];
|
||||||
|
if (accumulated + c.length > charIdx) {
|
||||||
|
return side === "start" ? starts[ci] : ends[ci];
|
||||||
|
}
|
||||||
|
accumulated += c.length;
|
||||||
|
}
|
||||||
|
const last = side === "start" ? starts[starts.length - 1] : ends[ends.length - 1];
|
||||||
|
return last ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: AudioChunk[] = [];
|
||||||
|
for (let wi = 0; wi < words.length; wi += 3) {
|
||||||
|
const slice = words.slice(wi, wi + 3);
|
||||||
|
chunks.push({
|
||||||
|
text: slice.map(w => w.word).join(" "),
|
||||||
|
start: timeForChar(slice[0].charStart, "start"),
|
||||||
|
end: timeForChar(slice[slice.length - 1].charEnd, "end"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { audio, chunks, cost };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Fish Audio doesnt return alignment data, so we return empty chunks
|
||||||
|
async function callFishAudio(
|
||||||
|
text: string,
|
||||||
|
apiKey: string,
|
||||||
|
voiceId: string
|
||||||
|
): Promise<{ audio: Buffer; chunks: AudioChunk[]; cost: number } | null> {
|
||||||
|
const res = await fetch("https://api.fish.audio/v1/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
reference_id: voiceId,
|
||||||
|
format: "mp3",
|
||||||
|
mp3_bitrate: 128,
|
||||||
|
streaming: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[tts] Fish Audio error ${res.status}: ${errText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = Buffer.from(await res.arrayBuffer());
|
||||||
|
return { audio, chunks: [], cost: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getProvider() {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
return (config.ttsProvider as string | undefined)?.toLowerCase() ?? "elevenlabs";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTTS(
|
||||||
|
text: string
|
||||||
|
): Promise<{ audio: Buffer; chunks: AudioChunk[]; cost: number } | null> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const provider = getProvider();
|
||||||
|
|
||||||
|
if (provider === "fishaudio") {
|
||||||
|
const apiKey = config.fishAudioApiKey as string;
|
||||||
|
const voiceId = config.fishAudioVoiceId as string;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
return callFishAudio(text, apiKey, voiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = config.elevenlabsApiKey as string;
|
||||||
|
const voiceId = config.elevenlabsVoiceId as string;
|
||||||
|
if (!apiKey) return null;
|
||||||
|
return callElevenLabs(text, apiKey, voiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function generateStepTTS(
|
||||||
|
text: string,
|
||||||
|
lessonId: string,
|
||||||
|
stepIndex: number
|
||||||
|
): Promise<TTSResult | null> {
|
||||||
|
try {
|
||||||
|
const result = await callTTS(text);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const filename = `step_${stepIndex}.mp3`;
|
||||||
|
await writeFile(`${dir}/${filename}`, result.audio);
|
||||||
|
|
||||||
|
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||||
|
console.log(`[tts] step ${stepIndex} for lesson ${lessonId} — ${result.chunks.length} chunks | $${result.cost.toFixed(4)}`);
|
||||||
|
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] step ${stepIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateQuestionTTS(
|
||||||
|
text: string,
|
||||||
|
lessonId: string,
|
||||||
|
stepIndex: number
|
||||||
|
): Promise<TTSResult | null> {
|
||||||
|
try {
|
||||||
|
const result = await callTTS(text);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const filename = `step_${stepIndex}_question.mp3`;
|
||||||
|
await writeFile(`${dir}/${filename}`, result.audio);
|
||||||
|
|
||||||
|
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||||
|
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] question ${stepIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateOptionTTS(
|
||||||
|
text: string,
|
||||||
|
lessonId: string,
|
||||||
|
stepIndex: number,
|
||||||
|
optionIndex: number
|
||||||
|
): Promise<{ audioPath: string; cost: number } | null> {
|
||||||
|
try {
|
||||||
|
const result = await callTTS(text);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const filename = `step_${stepIndex}_option_${optionIndex}.mp3`;
|
||||||
|
await writeFile(`${dir}/${filename}`, result.audio);
|
||||||
|
|
||||||
|
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||||
|
return { audioPath, cost: result.cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] option ${stepIndex}/${optionIndex} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate a plain audio clip with no timestamp data (used for label clips)
|
||||||
|
// opts are ElevenLabs-specific and are ignored when using Fish Audio
|
||||||
|
export async function generateClip(
|
||||||
|
text: string,
|
||||||
|
outPath: string,
|
||||||
|
apiKey: string,
|
||||||
|
voiceId: string,
|
||||||
|
opts?: { model?: string; voice_settings?: Record<string, number> }
|
||||||
|
): Promise<{ cost: number } | null> {
|
||||||
|
const provider = getProvider();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let buffer: Buffer;
|
||||||
|
let cost = 0;
|
||||||
|
|
||||||
|
if (provider === "fishaudio") {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const fishKey = config.fishAudioApiKey as string;
|
||||||
|
const fishVoice = (config.fishAudioVoiceId as string) || voiceId;
|
||||||
|
|
||||||
|
const res = await fetch("https://api.fish.audio/v1/tts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${fishKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
reference_id: fishVoice,
|
||||||
|
format: "mp3",
|
||||||
|
mp3_bitrate: 128,
|
||||||
|
streaming: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[tts] Fish Audio clip error ${res.status}: ${errText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
} else {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"xi-api-key": apiKey,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
model_id: opts?.model ?? "eleven_turbo_v2_5",
|
||||||
|
output_format: "mp3_44100_128",
|
||||||
|
...(opts?.voice_settings ? { voice_settings: opts.voice_settings } : {}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text().catch(() => "");
|
||||||
|
console.error(`[tts] clip error ${res.status}: ${errText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const charCost = parseInt(res.headers.get("character-cost") ?? "0", 10);
|
||||||
|
cost = charCost * 0.0003;
|
||||||
|
buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(resolve(process.cwd(), "public/audio/labels"), { recursive: true });
|
||||||
|
await writeFile(outPath, buffer);
|
||||||
|
return { cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] clip failed for ${outPath}: ${err?.message ?? err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTTSToPath(
|
||||||
|
text: string,
|
||||||
|
lessonId: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<TTSResult | null> {
|
||||||
|
try {
|
||||||
|
const result = await callTTS(text);
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const dir = resolve(process.cwd(), `public/audio/lessons/${lessonId}`);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(`${dir}/${filename}`, result.audio);
|
||||||
|
|
||||||
|
const audioPath = `/audio/lessons/${lessonId}/${filename}`;
|
||||||
|
return { audioPath, audioChunks: result.chunks, cost: result.cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[tts] ${filename} for lesson ${lessonId} failed: ${err?.message ?? err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
server/utils/openrouter.ts
Normal file
111
server/utils/openrouter.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
interface Message {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AskAIOptions {
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskAIResult {
|
||||||
|
text: string;
|
||||||
|
cost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function askAI(messages: Message[], options: AskAIOptions = {}): Promise<AskAIResult> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiKey = config.openrouterApiKey;
|
||||||
|
|
||||||
|
if (!apiKey) throw new Error("OPENROUTER_API_KEY is not set");
|
||||||
|
|
||||||
|
const model = options.model ?? (config as any).openrouterModel ?? "anthropic/claude-sonnet-4-5";
|
||||||
|
const maxRetries = options.maxRetries ?? 4;
|
||||||
|
|
||||||
|
let lastError: any;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
const label = attempt > 0 ? ` (attempt ${attempt + 1}/${maxRetries + 1})` : "";
|
||||||
|
const promptPreview = messages[messages.length - 1]?.content?.slice(0, 120).replace(/\n/g, " ");
|
||||||
|
console.log(`[openrouter] → ${model}${label} | prompt: "${promptPreview}…"`);
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://revisi.one",
|
||||||
|
"X-Title": "Revisi.one",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: options.temperature ?? 0.3,
|
||||||
|
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||||
|
const content = res.choices?.[0]?.message?.content;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
console.error(`[openrouter] ✗ empty response after ${elapsed}s — full response:`, JSON.stringify(res));
|
||||||
|
throw new Error("Empty response from OpenRouter");
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = res.usage;
|
||||||
|
const tokenInfo = usage ? ` | tokens: ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : "";
|
||||||
|
console.log(`[openrouter] ✓ ${elapsed}s${tokenInfo} | reply: "${content.slice(0, 120).replace(/\n/g, " ")}…"`);
|
||||||
|
|
||||||
|
const cost = usage?.cost ?? 0;
|
||||||
|
|
||||||
|
return { text: content, cost };
|
||||||
|
} catch (err: any) {
|
||||||
|
lastError = err;
|
||||||
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||||
|
const status = err?.response?.status ?? err?.statusCode ?? err?.status;
|
||||||
|
const body = err?.data ?? err?.response?._data ?? "(no body)";
|
||||||
|
|
||||||
|
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | error: ${err?.message}`);
|
||||||
|
if (body && body !== "(no body)") {
|
||||||
|
console.error(`[openrouter] response body:`, JSON.stringify(body).slice(0, 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 402 = insufficient credits — wait 60s and keep retrying indefinitely
|
||||||
|
if (status === 402) {
|
||||||
|
console.warn(`[openrouter] insufficient credits — waiting 60s before retry…`);
|
||||||
|
await sleep(60_000);
|
||||||
|
attempt--; // don't count against maxRetries
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only retry on 429 and 5xx
|
||||||
|
if (status !== 429 && (status < 500 || status > 599)) throw err;
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const retryAfterHeader = err?.response?.headers?.get?.("retry-after");
|
||||||
|
const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
|
||||||
|
const backoffMs = isNaN(retryAfterSec)
|
||||||
|
? Math.min(1000 * 2 ** attempt + Math.random() * 500, 30000)
|
||||||
|
: retryAfterSec * 1000;
|
||||||
|
|
||||||
|
console.warn(`[openrouter] retrying in ${Math.round(backoffMs)}ms…`);
|
||||||
|
await sleep(backoffMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
9
server/utils/parsePdf.ts
Normal file
9
server/utils/parsePdf.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pdfParse = require("pdf-parse");
|
||||||
|
|
||||||
|
export async function parsePdf(buffer: Buffer): Promise<string> {
|
||||||
|
const data = await pdfParse(buffer);
|
||||||
|
return data.text;
|
||||||
|
}
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
0
uploads/.gitkeep
Normal file
0
uploads/.gitkeep
Normal file
Loading…
Add table
Reference in a new issue