feat(course-plan): RAG sources + multi-modal media + assignments + student view
Some checks failed
Deploy Frontend to Staging / Deploy frontend to staging (push) Has been cancelled
Some checks failed
Deploy Frontend to Staging / Deploy frontend to staging (push) Has been cancelled
Builds the §24 product on top of the LangGraph runtime from §22:
Phase A (Sources / RAG)
- encoach.course.plan.source model (file | url | text)
- SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
text and embeds chunks via the existing pgvector pipeline scoped to
plan_id, so resources.search only returns the plan's own corpus
- Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval
Phase B (Deliverables)
- services.deliverables.compute_deliverables walks the plan, derives
{planned, generated, ready} per week from material + media state
- GET /api/ai/course-plan/<id>/deliverables drives the new wizard
preview step and the live progress strip on the detail page
Phase C (Multi-modal media)
- encoach.course.plan.media model + MediaService:
audio: AWS Polly (default) or ElevenLabs
image: OpenAI DALL-E 3, capped per plan via system parameter
video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
- Three new agent tools (media.synthesize_audio / generate_image /
compose_video), wired into course_week_materials and a new
course_media_director agent
- Endpoints per material + week-level batch generator
Phase D (Assignments)
- encoach.course.plan.assignment supports mode='batch' (op.batch) or
mode='students' (res.users), with due_date + message + state
- REST endpoints to list / create / delete assignments
Phase E (Student view)
- /api/student/course-plans + /api/student/course-plans/<id>
enforce visibility via assignment.expand_user_ids()
- New /student/course-plans list + read-only drilldown rendering
audio/image/video tiles from /web/content/<attachment_id>
Cross-cutting
- encoach.ai.tool.category: + media (so the new tools register)
- encoach.embedding gains a plan_id filter for plan-scoped RAG
- Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
per-material MediaDrawer
- ~280 new EN + AR i18n keys (full RTL coverage)
- smoke_course_plan.py exercises every phase via odoo-bin shell;
last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
fails cleanly when AWS creds aren't configured (expected)
Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.
Made-with: Cursor
This commit is contained in:
@@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
|
|||||||
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
|
||||||
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
|
||||||
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
|
||||||
|
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
|
||||||
|
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
|
||||||
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
|
||||||
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
|
||||||
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
|
||||||
@@ -269,6 +271,8 @@ const App = () => (
|
|||||||
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
<Route path="/student/placement/access" element={<PlacementAccess />} />
|
||||||
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
|
||||||
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
<Route path="/student/course/generate" element={<GapAnalysis />} />
|
||||||
|
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
|
||||||
|
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
|
||||||
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
|
||||||
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
|
||||||
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, ListChecks,
|
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||||
|
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
|
||||||
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||||
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const ar: Translations = {
|
|||||||
dashboard: "لوحة التحكم",
|
dashboard: "لوحة التحكم",
|
||||||
courses: "الدورات",
|
courses: "الدورات",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
|
myCoursePlans: "خططي الدراسية",
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
batches: "الدفعات",
|
batches: "الدفعات",
|
||||||
@@ -739,8 +740,14 @@ const ar: Translations = {
|
|||||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||||
coverage: "التغطية",
|
coverage: "التغطية",
|
||||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||||
|
sources: "المصادر المرجعية",
|
||||||
|
sourcesDesc:
|
||||||
|
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
|
||||||
scope: "النطاق",
|
scope: "النطاق",
|
||||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||||
|
media: "الوسائط",
|
||||||
|
mediaDesc:
|
||||||
|
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
|
||||||
review: "المراجعة",
|
review: "المراجعة",
|
||||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||||
},
|
},
|
||||||
@@ -767,7 +774,155 @@ const ar: Translations = {
|
|||||||
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
cefrRequired: "يرجى اختيار مستوى CEFR.",
|
||||||
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
sourcesCount_one: "{{count}} مصدر",
|
||||||
|
sourcesCount_other: "{{count}} مصادر",
|
||||||
|
noSources: "لا توجد مصادر",
|
||||||
|
mediaNone: "لا يوجد توليد وسائط",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
sections_extras: {
|
||||||
|
sources: "المصادر المرجعية",
|
||||||
|
progress: "تقدّم التوليد",
|
||||||
|
assignments: "الإسنادات",
|
||||||
|
media: "الوسائط المُولَّدة",
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
sectionTitle: "المصادر المرجعية (RAG)",
|
||||||
|
sectionDesc:
|
||||||
|
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
|
||||||
|
collected_one: "{{count}} مصدر جاهز",
|
||||||
|
collected_other: "{{count}} مصادر جاهزة",
|
||||||
|
empty: "لا توجد مصادر مرجعية بعد.",
|
||||||
|
dropTitle: "أضف مواد مرجعية",
|
||||||
|
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
|
||||||
|
uploadFiles: "رفع ملفات",
|
||||||
|
urlLabel: "إضافة رابط",
|
||||||
|
textLabel: "أو ألصق نصاً مباشرة",
|
||||||
|
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
|
||||||
|
uploadFailed: "تعذّر رفع \"{{name}}\".",
|
||||||
|
reindex: "إعادة فهرسة",
|
||||||
|
reindexed: "أُعيدت فهرسة المصدر.",
|
||||||
|
reindexFailed: "فشلت إعادة الفهرسة.",
|
||||||
|
deleted: "تم حذف المصدر.",
|
||||||
|
deleteFailed: "تعذّر حذف المصدر.",
|
||||||
|
status: {
|
||||||
|
pending: "في الانتظار",
|
||||||
|
indexing: "يُفهرس…",
|
||||||
|
indexed: "مُفهرس",
|
||||||
|
failed: "فشل",
|
||||||
|
},
|
||||||
|
kindLabel: {
|
||||||
|
file: "ملف",
|
||||||
|
url: "رابط",
|
||||||
|
text: "نص",
|
||||||
|
},
|
||||||
|
chunks_one: "{{count}} جزء",
|
||||||
|
chunks_other: "{{count}} أجزاء",
|
||||||
|
},
|
||||||
|
sourceKind: {
|
||||||
|
file: "ملف",
|
||||||
|
url: "رابط",
|
||||||
|
text: "نص",
|
||||||
|
},
|
||||||
|
deliverables: {
|
||||||
|
title: "تقدّم التوليد",
|
||||||
|
subtitle:
|
||||||
|
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
|
||||||
|
summary: {
|
||||||
|
planned_one: "{{count}} مخطّط",
|
||||||
|
planned_other: "{{count}} مخطّط",
|
||||||
|
generated_one: "{{count}} مُولَّد",
|
||||||
|
generated_other: "{{count}} مُولَّد",
|
||||||
|
ready_one: "{{count}} جاهز",
|
||||||
|
ready_other: "{{count}} جاهز",
|
||||||
|
},
|
||||||
|
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
|
||||||
|
status: {
|
||||||
|
planned: "مخطّط",
|
||||||
|
generated: "مُولَّد",
|
||||||
|
ready: "جاهز",
|
||||||
|
},
|
||||||
|
perWeek: "تفصيل لكل أسبوع",
|
||||||
|
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
audio: "صوت",
|
||||||
|
image: "صورة",
|
||||||
|
video: "فيديو",
|
||||||
|
audioTitle: "تعليق صوتي",
|
||||||
|
audioDesc:
|
||||||
|
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
|
||||||
|
imageTitle: "صور تغطية",
|
||||||
|
imageDesc:
|
||||||
|
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
|
||||||
|
videoTitle: "فيديو شرائح",
|
||||||
|
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
|
||||||
|
hint:
|
||||||
|
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
|
||||||
|
drawerTitle: "وسائط \"{{title}}\"",
|
||||||
|
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
|
||||||
|
generateAudio: "توليد صوت",
|
||||||
|
generateImage: "توليد صورة",
|
||||||
|
generateVideo: "توليد فيديو",
|
||||||
|
generating: "جاري التوليد…",
|
||||||
|
audioReady: "الصوت جاهز",
|
||||||
|
imageReady: "الصورة جاهزة",
|
||||||
|
videoReady: "الفيديو جاهز",
|
||||||
|
audioFailed: "فشل توليد الصوت.",
|
||||||
|
imageFailed: "فشل توليد الصورة.",
|
||||||
|
videoFailed: "فشل توليد الفيديو.",
|
||||||
|
deleted: "تم حذف الوسيط.",
|
||||||
|
deleteFailed: "تعذّر حذف الوسيط.",
|
||||||
|
open: "فتح",
|
||||||
|
download: "تنزيل",
|
||||||
|
noMedia: "لا توجد وسائط لهذه المادة بعد.",
|
||||||
|
mediaForMaterial: "وسائط",
|
||||||
|
bulk: "توليد وسائط الأسبوع",
|
||||||
|
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
|
||||||
|
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
|
||||||
|
provider: "المزوّد",
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
title: "مُسندة إلى",
|
||||||
|
empty: "لم تُسند إلى أحد بعد.",
|
||||||
|
assign: "إسناد",
|
||||||
|
assignTitle: "إسناد خطة المقرر",
|
||||||
|
assignSubtitle:
|
||||||
|
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
|
||||||
|
modeBatch: "دفعة كاملة",
|
||||||
|
modeStudents: "طلاب محدّدون",
|
||||||
|
pickBatch: "اختر دفعة",
|
||||||
|
pickStudents: "اختر طلاباً",
|
||||||
|
noBatches: "لا توجد دفعات.",
|
||||||
|
noStudents: "لا يوجد طلاب.",
|
||||||
|
dueDate: "تاريخ التسليم (اختياري)",
|
||||||
|
message: "رسالة للمتعلّمين (اختياري)",
|
||||||
|
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
|
||||||
|
created: "تم إسناد الخطة.",
|
||||||
|
createFailed: "تعذّر إسناد الخطة.",
|
||||||
|
removed: "تمت إزالة الإسناد.",
|
||||||
|
removeFailed: "تعذّر إزالة الإسناد.",
|
||||||
|
remove: "إزالة",
|
||||||
|
confirmRemove: "إزالة هذا الإسناد؟",
|
||||||
|
assignedBy: "أسندها {{name}}",
|
||||||
|
students_one: "{{count}} طالب",
|
||||||
|
students_other: "{{count}} طلاب",
|
||||||
|
cancel: "إلغاء",
|
||||||
|
save: "إسناد",
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
listTitle: "خططي الدراسية",
|
||||||
|
listSubtitle:
|
||||||
|
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
|
||||||
|
empty: "لم تُسند إليك خطط بعد.",
|
||||||
|
open: "فتح المقرر",
|
||||||
|
assignedBy: "أسندها {{name}}",
|
||||||
|
due: "موعد التسليم {{date}}",
|
||||||
|
noDue: "بدون موعد",
|
||||||
|
backToList: "العودة إلى خططي",
|
||||||
|
},
|
||||||
|
wizardSourcesStep: "المصادر",
|
||||||
},
|
},
|
||||||
aiAdmin: {
|
aiAdmin: {
|
||||||
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
title: "وكلاء الذكاء الاصطناعي والأدوات",
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const en: Translations = {
|
|||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
courses: "Courses",
|
courses: "Courses",
|
||||||
myCourses: "My Courses",
|
myCourses: "My Courses",
|
||||||
|
myCoursePlans: "My Course Plans",
|
||||||
students: "Students",
|
students: "Students",
|
||||||
teachers: "Teachers",
|
teachers: "Teachers",
|
||||||
batches: "Batches",
|
batches: "Batches",
|
||||||
@@ -796,8 +797,14 @@ const en: Translations = {
|
|||||||
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
basicsDesc: "Name the course and set level, duration, and weekly hours.",
|
||||||
coverage: "Coverage",
|
coverage: "Coverage",
|
||||||
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
|
||||||
|
sources: "Reference sources",
|
||||||
|
sourcesDesc:
|
||||||
|
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
|
||||||
scope: "Scope",
|
scope: "Scope",
|
||||||
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
|
||||||
|
media: "Multimedia",
|
||||||
|
mediaDesc:
|
||||||
|
"Pick which media the AI should auto-produce alongside the text materials.",
|
||||||
review: "Review",
|
review: "Review",
|
||||||
reviewDesc: "Double-check your brief before kicking off the generation.",
|
reviewDesc: "Double-check your brief before kicking off the generation.",
|
||||||
},
|
},
|
||||||
@@ -825,7 +832,156 @@ const en: Translations = {
|
|||||||
cefrRequired: "Please pick a CEFR level.",
|
cefrRequired: "Please pick a CEFR level.",
|
||||||
weeksRange: "Total weeks must be at least 1.",
|
weeksRange: "Total weeks must be at least 1.",
|
||||||
},
|
},
|
||||||
|
review: {
|
||||||
|
sourcesCount_one: "{{count}} reference source",
|
||||||
|
sourcesCount_other: "{{count}} reference sources",
|
||||||
|
noSources: "No reference sources",
|
||||||
|
mediaNone: "No media generation",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
sections_extras: {
|
||||||
|
sources: "Reference sources",
|
||||||
|
progress: "Generation progress",
|
||||||
|
assignments: "Assignments",
|
||||||
|
media: "Generated media",
|
||||||
|
},
|
||||||
|
sources: {
|
||||||
|
sectionTitle: "Reference sources (RAG)",
|
||||||
|
sectionDesc:
|
||||||
|
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
|
||||||
|
collected_one: "{{count}} source ready",
|
||||||
|
collected_other: "{{count}} sources ready",
|
||||||
|
empty: "No reference sources yet.",
|
||||||
|
dropTitle: "Add reference materials",
|
||||||
|
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
|
||||||
|
uploadFiles: "Upload files",
|
||||||
|
urlLabel: "Add a URL",
|
||||||
|
textLabel: "Or paste text directly",
|
||||||
|
textPlaceholder: "Paste a passage to ground the AI on…",
|
||||||
|
uploadFailed: "Couldn't upload \"{{name}}\".",
|
||||||
|
reindex: "Reindex",
|
||||||
|
reindexed: "Source reindexed.",
|
||||||
|
reindexFailed: "Reindex failed.",
|
||||||
|
deleted: "Source deleted.",
|
||||||
|
deleteFailed: "Couldn't delete source.",
|
||||||
|
status: {
|
||||||
|
pending: "Pending",
|
||||||
|
indexing: "Indexing…",
|
||||||
|
indexed: "Indexed",
|
||||||
|
failed: "Failed",
|
||||||
|
},
|
||||||
|
kindLabel: {
|
||||||
|
file: "File",
|
||||||
|
url: "URL",
|
||||||
|
text: "Text",
|
||||||
|
},
|
||||||
|
chunks_one: "{{count}} chunk",
|
||||||
|
chunks_other: "{{count}} chunks",
|
||||||
|
},
|
||||||
|
sourceKind: {
|
||||||
|
file: "File",
|
||||||
|
url: "URL",
|
||||||
|
text: "Inline text",
|
||||||
|
},
|
||||||
|
deliverables: {
|
||||||
|
title: "Generation progress",
|
||||||
|
subtitle:
|
||||||
|
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
|
||||||
|
summary: {
|
||||||
|
planned_one: "{{count}} planned",
|
||||||
|
planned_other: "{{count}} planned",
|
||||||
|
generated_one: "{{count}} generated",
|
||||||
|
generated_other: "{{count}} generated",
|
||||||
|
ready_one: "{{count}} ready",
|
||||||
|
ready_other: "{{count}} ready",
|
||||||
|
},
|
||||||
|
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
|
||||||
|
status: {
|
||||||
|
planned: "Planned",
|
||||||
|
generated: "Generated",
|
||||||
|
ready: "Ready",
|
||||||
|
},
|
||||||
|
perWeek: "Per-week breakdown",
|
||||||
|
noWeeks: "No weeks yet — generate the plan first.",
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
audio: "Audio",
|
||||||
|
image: "Image",
|
||||||
|
video: "Video",
|
||||||
|
audioTitle: "Narration audio",
|
||||||
|
audioDesc:
|
||||||
|
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
|
||||||
|
imageTitle: "Cover images",
|
||||||
|
imageDesc:
|
||||||
|
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
|
||||||
|
videoTitle: "Slideshow video",
|
||||||
|
videoDesc:
|
||||||
|
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
|
||||||
|
hint:
|
||||||
|
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
|
||||||
|
drawerTitle: "Media for \"{{title}}\"",
|
||||||
|
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
|
||||||
|
generateAudio: "Generate audio",
|
||||||
|
generateImage: "Generate image",
|
||||||
|
generateVideo: "Generate video",
|
||||||
|
generating: "Generating…",
|
||||||
|
audioReady: "Audio ready",
|
||||||
|
imageReady: "Image ready",
|
||||||
|
videoReady: "Video ready",
|
||||||
|
audioFailed: "Audio generation failed.",
|
||||||
|
imageFailed: "Image generation failed.",
|
||||||
|
videoFailed: "Video generation failed.",
|
||||||
|
deleted: "Media deleted.",
|
||||||
|
deleteFailed: "Couldn't delete media.",
|
||||||
|
open: "Open",
|
||||||
|
download: "Download",
|
||||||
|
noMedia: "No media yet for this material.",
|
||||||
|
mediaForMaterial: "Media",
|
||||||
|
bulk: "Generate week media",
|
||||||
|
bulkSuccess: "Week media generation done.",
|
||||||
|
bulkFailed: "Couldn't generate week media.",
|
||||||
|
provider: "Provider",
|
||||||
|
},
|
||||||
|
assignments: {
|
||||||
|
title: "Assigned to",
|
||||||
|
empty: "Not assigned to anyone yet.",
|
||||||
|
assign: "Assign",
|
||||||
|
assignTitle: "Assign this course plan",
|
||||||
|
assignSubtitle:
|
||||||
|
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
|
||||||
|
modeBatch: "Whole class (batch)",
|
||||||
|
modeStudents: "Individual students",
|
||||||
|
pickBatch: "Select a batch",
|
||||||
|
pickStudents: "Pick students",
|
||||||
|
noBatches: "No batches available.",
|
||||||
|
noStudents: "No students available.",
|
||||||
|
dueDate: "Due date (optional)",
|
||||||
|
message: "Message to learners (optional)",
|
||||||
|
messagePlaceholder: "Welcome note, expectations, deadlines…",
|
||||||
|
created: "Plan assigned.",
|
||||||
|
createFailed: "Couldn't assign plan.",
|
||||||
|
removed: "Assignment removed.",
|
||||||
|
removeFailed: "Couldn't remove assignment.",
|
||||||
|
remove: "Remove",
|
||||||
|
confirmRemove: "Remove this assignment?",
|
||||||
|
assignedBy: "Assigned by {{name}}",
|
||||||
|
students_one: "{{count}} student",
|
||||||
|
students_other: "{{count}} students",
|
||||||
|
cancel: "Cancel",
|
||||||
|
save: "Assign",
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
listTitle: "My course plans",
|
||||||
|
listSubtitle:
|
||||||
|
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
|
||||||
|
empty: "No plans assigned to you yet.",
|
||||||
|
open: "Open course",
|
||||||
|
assignedBy: "Assigned by {{name}}",
|
||||||
|
due: "Due {{date}}",
|
||||||
|
noDue: "No deadline",
|
||||||
|
backToList: "Back to my plans",
|
||||||
|
},
|
||||||
|
wizardSourcesStep: "Sources",
|
||||||
},
|
},
|
||||||
aiAdmin: {
|
aiAdmin: {
|
||||||
title: "AI Agents & Tools",
|
title: "AI Agents & Tools",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { X } from "lucide-react";
|
import {
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
Mic,
|
||||||
|
Music,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
Video,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
|
|||||||
* materials immediately.
|
* materials immediately.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type DraftSourceKind = "file" | "url" | "text";
|
||||||
|
|
||||||
|
interface DraftSource {
|
||||||
|
/** Stable client-side id; not persisted. */
|
||||||
|
uid: string;
|
||||||
|
kind: DraftSourceKind;
|
||||||
|
name: string;
|
||||||
|
/** Only set when kind === "file". */
|
||||||
|
file?: File;
|
||||||
|
/** Only set when kind === "url". */
|
||||||
|
url?: string;
|
||||||
|
/** Only set when kind === "text". */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaToggleState {
|
||||||
|
/** Generate narration audio (Polly / ElevenLabs) for listening +
|
||||||
|
* speaking materials right after each week's materials are produced. */
|
||||||
|
audio: boolean;
|
||||||
|
/** Generate DALL-E images for reading texts, listening scripts, and
|
||||||
|
* vocabulary lists. Bound to the per-plan image budget. */
|
||||||
|
image: boolean;
|
||||||
|
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
|
||||||
|
* option; off by default. */
|
||||||
|
video: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CoursePlanWizardState {
|
interface CoursePlanWizardState {
|
||||||
title: string;
|
title: string;
|
||||||
cefr_level: string;
|
cefr_level: string;
|
||||||
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
|
|||||||
grammar_focus: string[];
|
grammar_focus: string[];
|
||||||
resources: string[];
|
resources: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
|
sources: DraftSource[];
|
||||||
|
media: MediaToggleState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CEFR_OPTIONS = [
|
const CEFR_OPTIONS = [
|
||||||
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "sources",
|
||||||
|
titleKey: "coursePlan.wizard.steps.sources",
|
||||||
|
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
|
||||||
|
render: ({ state, update }) => (
|
||||||
|
<SourcesStep
|
||||||
|
sources={state.sources}
|
||||||
|
onChange={(sources) => update({ sources })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "scope",
|
id: "scope",
|
||||||
titleKey: "coursePlan.wizard.steps.scope",
|
titleKey: "coursePlan.wizard.steps.scope",
|
||||||
@@ -214,6 +264,17 @@ export default function CoursePlanWizard() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
titleKey: "coursePlan.wizard.steps.media",
|
||||||
|
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
|
||||||
|
render: ({ state, update }) => (
|
||||||
|
<MediaStep
|
||||||
|
media={state.media}
|
||||||
|
onChange={(media) => update({ media })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "review",
|
id: "review",
|
||||||
titleKey: "coursePlan.wizard.steps.review",
|
titleKey: "coursePlan.wizard.steps.review",
|
||||||
@@ -257,6 +318,26 @@ export default function CoursePlanWizard() {
|
|||||||
label={t("coursePlan.wizard.fields.notes")}
|
label={t("coursePlan.wizard.fields.notes")}
|
||||||
value={state.notes || "—"}
|
value={state.notes || "—"}
|
||||||
/>
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label={t("coursePlan.wizard.steps.sources")}
|
||||||
|
value={
|
||||||
|
state.sources.length
|
||||||
|
? t("coursePlan.wizard.review.sourcesCount", {
|
||||||
|
count: state.sources.length,
|
||||||
|
})
|
||||||
|
: t("coursePlan.wizard.review.noSources")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label={t("coursePlan.wizard.steps.media")}
|
||||||
|
value={[
|
||||||
|
state.media.audio ? t("coursePlan.media.audio") : null,
|
||||||
|
state.media.image ? t("coursePlan.media.image") : null,
|
||||||
|
state.media.video ? t("coursePlan.media.video") : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
|
||||||
|
/>
|
||||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||||
{t("coursePlan.wizard.reviewHint")}
|
{t("coursePlan.wizard.reviewHint")}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,9 +364,11 @@ export default function CoursePlanWizard() {
|
|||||||
grammar_focus: [],
|
grammar_focus: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
notes: "",
|
notes: "",
|
||||||
|
sources: [],
|
||||||
|
media: { audio: true, image: true, video: false },
|
||||||
}}
|
}}
|
||||||
onFinish={async (state) => {
|
onFinish={async (state) => {
|
||||||
await mutation.mutateAsync({
|
const resp = await mutation.mutateAsync({
|
||||||
title: state.title.trim(),
|
title: state.title.trim(),
|
||||||
cefr_level: state.cefr_level,
|
cefr_level: state.cefr_level,
|
||||||
total_weeks: state.total_weeks,
|
total_weeks: state.total_weeks,
|
||||||
@@ -298,11 +381,284 @@ export default function CoursePlanWizard() {
|
|||||||
resources: state.resources.length ? state.resources : undefined,
|
resources: state.resources.length ? state.resources : undefined,
|
||||||
notes: state.notes.trim() || undefined,
|
notes: state.notes.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
const planId = resp?.data?.id;
|
||||||
|
if (planId && state.sources.length) {
|
||||||
|
// Best-effort upload — we keep going even if a single one fails so
|
||||||
|
// the user lands on the detail page with whatever did make it in.
|
||||||
|
for (const src of state.sources) {
|
||||||
|
try {
|
||||||
|
if (src.kind === "file" && src.file) {
|
||||||
|
await coursePlanService.uploadSource(planId, src.file, {
|
||||||
|
name: src.name || src.file.name,
|
||||||
|
});
|
||||||
|
} else if (src.kind === "url" && src.url) {
|
||||||
|
await coursePlanService.createSource(planId, {
|
||||||
|
kind: "url",
|
||||||
|
url: src.url,
|
||||||
|
name: src.name || src.url,
|
||||||
|
});
|
||||||
|
} else if (src.kind === "text" && src.text) {
|
||||||
|
await coursePlanService.createSource(planId, {
|
||||||
|
kind: "text",
|
||||||
|
inline_text: src.text,
|
||||||
|
name: src.name || t("coursePlan.sourceKind.text"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(
|
||||||
|
describeApiError(err, t("coursePlan.sources.uploadFailed", {
|
||||||
|
name: src.name || src.kind,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function genUid() {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourcesStep({
|
||||||
|
sources,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
sources: DraftSource[];
|
||||||
|
onChange: (next: DraftSource[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [draftUrl, setDraftUrl] = useState("");
|
||||||
|
const [draftText, setDraftText] = useState("");
|
||||||
|
|
||||||
|
const addFiles = (files: FileList | null) => {
|
||||||
|
if (!files || !files.length) return;
|
||||||
|
const additions: DraftSource[] = [];
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
additions.push({
|
||||||
|
uid: genUid(),
|
||||||
|
kind: "file",
|
||||||
|
name: f.name,
|
||||||
|
file: f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onChange([...sources, ...additions]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUrl = () => {
|
||||||
|
const url = draftUrl.trim();
|
||||||
|
if (!url) return;
|
||||||
|
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
|
||||||
|
setDraftUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const addText = () => {
|
||||||
|
const text = draftText.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onChange([
|
||||||
|
...sources,
|
||||||
|
{
|
||||||
|
uid: genUid(),
|
||||||
|
kind: "text",
|
||||||
|
name: text.slice(0, 60).replace(/\s+/g, " "),
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setDraftText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
|
||||||
|
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="mt-2 text-sm font-medium">
|
||||||
|
{t("coursePlan.sources.dropTitle")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.dropHint")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.currentTarget.files);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-4 w-4" />
|
||||||
|
{t("coursePlan.sources.uploadFiles")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>{t("coursePlan.sources.urlLabel")}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={draftUrl}
|
||||||
|
onChange={(e) => setDraftUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
addUrl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addUrl}
|
||||||
|
disabled={!draftUrl.trim()}
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t("coursePlan.sources.textLabel")}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
value={draftText}
|
||||||
|
onChange={(e) => setDraftText(e.target.value)}
|
||||||
|
placeholder={t("coursePlan.sources.textPlaceholder")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addText}
|
||||||
|
disabled={!draftText.trim()}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sources.length > 0 && (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("coursePlan.sources.collected", { count: sources.length })}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{sources.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.uid}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="capitalize text-[10px]">
|
||||||
|
{s.kind}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex-1 truncate">{s.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => remove(s.uid)}
|
||||||
|
aria-label={t("common.remove", "Remove")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaStep({
|
||||||
|
media,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
media: MediaToggleState;
|
||||||
|
onChange: (next: MediaToggleState) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={Music}
|
||||||
|
title={t("coursePlan.media.audioTitle")}
|
||||||
|
description={t("coursePlan.media.audioDesc")}
|
||||||
|
checked={media.audio}
|
||||||
|
onToggle={(v) => onChange({ ...media, audio: v })}
|
||||||
|
/>
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={ImageIcon}
|
||||||
|
title={t("coursePlan.media.imageTitle")}
|
||||||
|
description={t("coursePlan.media.imageDesc")}
|
||||||
|
checked={media.image}
|
||||||
|
onToggle={(v) => onChange({ ...media, image: v })}
|
||||||
|
/>
|
||||||
|
<MediaToggleCard
|
||||||
|
icon={Video}
|
||||||
|
title={t("coursePlan.media.videoTitle")}
|
||||||
|
description={t("coursePlan.media.videoDesc")}
|
||||||
|
checked={media.video}
|
||||||
|
onToggle={(v) => onChange({ ...media, video: v })}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<Mic className="h-3.5 w-3.5" />
|
||||||
|
{t("coursePlan.media.hint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaToggleCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
icon: typeof Music;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(!checked)}
|
||||||
|
className={[
|
||||||
|
"rounded-md border p-3 text-left transition-colors",
|
||||||
|
checked
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/40",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
<div className="font-medium text-sm">{title}</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
|||||||
311
src/pages/student/StudentCoursePlanDetail.tsx
Normal file
311
src/pages/student/StudentCoursePlanDetail.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
ClipboardList,
|
||||||
|
Headphones,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Library,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Music,
|
||||||
|
PenSquare,
|
||||||
|
Sparkles,
|
||||||
|
Type,
|
||||||
|
Video,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
import { describeApiError } from "@/lib/api-client";
|
||||||
|
import type {
|
||||||
|
CoursePlan,
|
||||||
|
CoursePlanMaterial,
|
||||||
|
CoursePlanMedia,
|
||||||
|
CoursePlanWeek,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
const SKILL_ICONS: Record<string, LucideIcon> = {
|
||||||
|
reading: BookOpen,
|
||||||
|
writing: PenSquare,
|
||||||
|
listening: Headphones,
|
||||||
|
speaking: Mic,
|
||||||
|
grammar: Type,
|
||||||
|
vocabulary: Library,
|
||||||
|
integrated: MessageSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student detail view for an assigned AI course plan.
|
||||||
|
*
|
||||||
|
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
|
||||||
|
* returns 403 unless the current user is in the plan's assignment list,
|
||||||
|
* so we can render unconditionally as soon as the query resolves.
|
||||||
|
*
|
||||||
|
* The page is intentionally read-only — students can play audio, view
|
||||||
|
* images, and watch video, but they can't (re)generate content.
|
||||||
|
*/
|
||||||
|
export default function StudentCoursePlanDetail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { planId: planIdRaw } = useParams<{ planId: string }>();
|
||||||
|
const planId = Number(planIdRaw);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["student-course-plan", planId],
|
||||||
|
queryFn: () => coursePlanService.studentGet(planId),
|
||||||
|
enabled: Number.isFinite(planId) && planId > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = data?.data as CoursePlan | undefined;
|
||||||
|
|
||||||
|
const materialsByWeek = useMemo(() => {
|
||||||
|
const map = new Map<number, CoursePlanMaterial[]>();
|
||||||
|
if (!plan?.materials) return map;
|
||||||
|
for (const m of plan.materials) {
|
||||||
|
const list = map.get(m.week_number) ?? [];
|
||||||
|
list.push(m);
|
||||||
|
map.set(m.week_number, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [plan?.materials]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
||||||
|
<Link to="/student/course-plans" className="flex items-center gap-1">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{t("coursePlan.student.backToList")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-20" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-2xl">{plan.name}</CardTitle>
|
||||||
|
{plan.description && (
|
||||||
|
<CardDescription className="max-w-3xl">
|
||||||
|
{plan.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Badge variant="secondary" className="uppercase">
|
||||||
|
{plan.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.hoursPerWeek", {
|
||||||
|
count: plan.contact_hours_per_week,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{plan.assignment && (
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
{plan.assignment.due_date
|
||||||
|
? t("coursePlan.student.due", { date: plan.assignment.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
{plan.assignment.assigned_by_name && (
|
||||||
|
<span>
|
||||||
|
·{" "}
|
||||||
|
{t("coursePlan.student.assignedBy", {
|
||||||
|
name: plan.assignment.assigned_by_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{plan.assignment?.message && (
|
||||||
|
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
|
||||||
|
{plan.assignment.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{plan.objectives.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.objectives")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
{plan.objectives.map((o, i) => (
|
||||||
|
<li key={i}>{o}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan.weeks && plan.weeks.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
{t("coursePlan.sections.delivery")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="multiple" className="w-full">
|
||||||
|
{plan.weeks.map((w) => (
|
||||||
|
<StudentWeek
|
||||||
|
key={w.id}
|
||||||
|
week={w}
|
||||||
|
materials={materialsByWeek.get(w.week_number) ?? []}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentWeek({
|
||||||
|
week,
|
||||||
|
materials,
|
||||||
|
}: {
|
||||||
|
week: CoursePlanWeek;
|
||||||
|
materials: CoursePlanMaterial[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<AccordionItem value={String(week.week_number)}>
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
<div className="flex-1 flex items-center gap-3 min-w-0">
|
||||||
|
<Badge variant="outline" className="shrink-0">
|
||||||
|
{t("coursePlan.weekN", { n: week.week_number })}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{week.focus || week.unit || "—"}
|
||||||
|
</div>
|
||||||
|
{week.date_label && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{week.date_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3">
|
||||||
|
{materials.length === 0 && (
|
||||||
|
<p className="text-sm italic text-muted-foreground">
|
||||||
|
{t("coursePlan.media.noMedia")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{materials.map((m) => (
|
||||||
|
<StudentMaterial key={m.id} material={m} />
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
<CardTitle className="text-base flex-1 min-w-0">
|
||||||
|
{material.title}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{t(
|
||||||
|
`coursePlan.materialType.${material.material_type}`,
|
||||||
|
material.material_type,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{material.summary && <CardDescription>{material.summary}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{(material.media ?? []).map((m) => (
|
||||||
|
<StudentMediaTile key={m.id} media={m} />
|
||||||
|
))}
|
||||||
|
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
|
||||||
|
{material.body_text || JSON.stringify(material.body, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
|
||||||
|
const url = media.download_url || "";
|
||||||
|
if (!url) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{media.kind === "audio" && <Music className="h-4 w-4" />}
|
||||||
|
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
|
||||||
|
{media.kind === "video" && <Video className="h-4 w-4" />}
|
||||||
|
<span className="capitalize text-xs text-muted-foreground">
|
||||||
|
{media.kind}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
|
||||||
|
{media.kind === "image" && (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={media.title}
|
||||||
|
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{media.kind === "video" && (
|
||||||
|
<video src={url} controls className="w-full rounded-md max-h-72" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/pages/student/StudentCoursePlans.tsx
Normal file
147
src/pages/student/StudentCoursePlans.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
|
GraduationCap,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { coursePlanService } from "@/services/coursePlan.service";
|
||||||
|
import { describeApiError } from "@/lib/api-client";
|
||||||
|
import type { CoursePlan } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student-facing list of AI course plans assigned to the current user.
|
||||||
|
*
|
||||||
|
* Reads from `GET /api/student/course-plans`, which only returns plans
|
||||||
|
* the user has been linked to either directly (Phase D `students` mode)
|
||||||
|
* or via the batch they're enrolled in. The card itself is intentionally
|
||||||
|
* lightweight: tap → drill into `/student/course-plans/:id` for the full
|
||||||
|
* weekly plan and embedded media.
|
||||||
|
*/
|
||||||
|
export default function StudentCoursePlans() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["student-course-plans"],
|
||||||
|
queryFn: () => coursePlanService.studentList(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-2xl">
|
||||||
|
{t("coursePlan.student.listSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-44 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && items.length === 0 && (
|
||||||
|
<div className="text-center py-12 border rounded-lg bg-muted/20">
|
||||||
|
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("coursePlan.student.empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((plan) => (
|
||||||
|
<PlanCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanCard({ plan }: { plan: CoursePlan }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const a = plan.assignment;
|
||||||
|
return (
|
||||||
|
<Card className="hover:shadow-md transition-shadow flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
|
||||||
|
{plan.name}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="uppercase shrink-0">
|
||||||
|
{plan.cefr_level}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{plan.description && (
|
||||||
|
<CardDescription className="line-clamp-2">
|
||||||
|
{plan.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t("coursePlan.hoursPerWeek", {
|
||||||
|
count: plan.contact_hours_per_week,
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{a && (
|
||||||
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
{a.assigned_by_name && (
|
||||||
|
<div>
|
||||||
|
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{a.due_date
|
||||||
|
? t("coursePlan.student.due", { date: a.due_date })
|
||||||
|
: t("coursePlan.student.noDue")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
|
<Button asChild size="sm" className="w-full">
|
||||||
|
<Link to={`/student/course-plans/${plan.id}`}>
|
||||||
|
{t("coursePlan.student.open")}
|
||||||
|
<ArrowRight className="ml-1 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import type {
|
import type {
|
||||||
CoursePlan,
|
CoursePlan,
|
||||||
|
CoursePlanAssignment,
|
||||||
|
CoursePlanDeliverables,
|
||||||
CoursePlanGenerateBrief,
|
CoursePlanGenerateBrief,
|
||||||
CoursePlanMaterial,
|
CoursePlanMaterial,
|
||||||
|
CoursePlanMedia,
|
||||||
|
CoursePlanSource,
|
||||||
|
CoursePlanSourceKind,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +17,9 @@ import type {
|
|||||||
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
|
||||||
*/
|
*/
|
||||||
export const coursePlanService = {
|
export const coursePlanService = {
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Plan lifecycle
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
async list(params?: {
|
async list(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -45,4 +53,166 @@ export const coursePlanService = {
|
|||||||
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
|
||||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase A — Sources
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/sources`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSource(
|
||||||
|
planId: number,
|
||||||
|
payload:
|
||||||
|
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
|
||||||
|
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
return api.post(`/ai/course-plan/${planId}/sources`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadSource(
|
||||||
|
planId: number,
|
||||||
|
file: File,
|
||||||
|
opts?: { name?: string; auto_index?: boolean },
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("kind", "file" satisfies CoursePlanSourceKind);
|
||||||
|
if (opts?.name) fd.append("name", opts.name);
|
||||||
|
if (opts?.auto_index !== undefined) {
|
||||||
|
fd.append("auto_index", opts.auto_index ? "1" : "0");
|
||||||
|
}
|
||||||
|
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reindexSource(
|
||||||
|
planId: number,
|
||||||
|
sourceId: number,
|
||||||
|
): Promise<{ data: CoursePlanSource }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSource(
|
||||||
|
planId: number,
|
||||||
|
sourceId: number,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase B — Deliverables / progress
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/deliverables`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase C — Multimedia
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listMaterialMedia(
|
||||||
|
materialId: number,
|
||||||
|
): Promise<{ items: CoursePlanMedia[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/material/${materialId}/media`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateAudio(
|
||||||
|
materialId: number,
|
||||||
|
payload?: {
|
||||||
|
voice?: string;
|
||||||
|
language?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
provider?: "polly" | "elevenlabs";
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/audio`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateImage(
|
||||||
|
materialId: number,
|
||||||
|
payload?: {
|
||||||
|
prompt?: string;
|
||||||
|
size?: "1024x1024" | "1792x1024" | "1024x1792";
|
||||||
|
style?: "natural" | "vivid";
|
||||||
|
quality?: "standard" | "hd";
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/image`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/material/${materialId}/media/video`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(`/ai/course-plan/media/${mediaId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateWeekMedia(
|
||||||
|
planId: number,
|
||||||
|
weekNumber: number,
|
||||||
|
payload?: { kinds?: Array<"audio" | "image" | "video"> },
|
||||||
|
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
|
||||||
|
return api.post(
|
||||||
|
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
|
||||||
|
payload ?? {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase D — Assignments
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async listAssignments(
|
||||||
|
planId: number,
|
||||||
|
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
|
||||||
|
return api.get(`/ai/course-plan/${planId}/assignments`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createAssignment(
|
||||||
|
planId: number,
|
||||||
|
payload:
|
||||||
|
| {
|
||||||
|
mode: "batch";
|
||||||
|
batch_id: number;
|
||||||
|
due_date?: string | null;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: "students";
|
||||||
|
student_user_ids: number[];
|
||||||
|
due_date?: string | null;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
): Promise<{ data: CoursePlanAssignment }> {
|
||||||
|
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAssignment(
|
||||||
|
planId: number,
|
||||||
|
assignmentId: number,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
return api.delete(
|
||||||
|
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Phase E — Student-side
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
|
||||||
|
return api.get("/student/course-plans");
|
||||||
|
},
|
||||||
|
|
||||||
|
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
|
||||||
|
return api.get(`/student/course-plans/${planId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,142 @@ export interface CoursePlanMaterial {
|
|||||||
/** Loose shape: depends on material_type. */
|
/** Loose shape: depends on material_type. */
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
body_text: string;
|
body_text: string;
|
||||||
|
media?: CoursePlanMedia[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase A — Reference sources
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanSourceKind = "file" | "url" | "text";
|
||||||
|
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
|
||||||
|
|
||||||
|
export interface CoursePlanSource {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
name: string;
|
||||||
|
kind: CoursePlanSourceKind;
|
||||||
|
file_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
url: string;
|
||||||
|
has_inline_text: boolean;
|
||||||
|
status: CoursePlanSourceStatus;
|
||||||
|
error: string;
|
||||||
|
chunks_count: number;
|
||||||
|
extracted_chars: number;
|
||||||
|
indexed_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase B — Deliverables preview / progress
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DeliverableStatus = "planned" | "generated" | "ready";
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverable {
|
||||||
|
skill: string;
|
||||||
|
material_type: string;
|
||||||
|
material_id: number | null;
|
||||||
|
title: string;
|
||||||
|
status: DeliverableStatus;
|
||||||
|
media: Array<{
|
||||||
|
id: number;
|
||||||
|
kind: "audio" | "image" | "video";
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverablesWeek {
|
||||||
|
week_number: number;
|
||||||
|
date_label: string;
|
||||||
|
unit: string;
|
||||||
|
focus: string;
|
||||||
|
items_total: number;
|
||||||
|
deliverables: CoursePlanDeliverable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoursePlanDeliverables {
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
planned: number;
|
||||||
|
generated: number;
|
||||||
|
ready: number;
|
||||||
|
percent_ready: number;
|
||||||
|
media: {
|
||||||
|
audio: number;
|
||||||
|
image: number;
|
||||||
|
video: number;
|
||||||
|
audio_ready: number;
|
||||||
|
image_ready: number;
|
||||||
|
video_ready: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
weeks: CoursePlanDeliverablesWeek[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase C — Multimedia
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanMediaKind = "audio" | "image" | "video";
|
||||||
|
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
|
||||||
|
export type CoursePlanMediaProvider =
|
||||||
|
| "polly"
|
||||||
|
| "elevenlabs"
|
||||||
|
| "openai_image"
|
||||||
|
| "ffmpeg"
|
||||||
|
| "elai"
|
||||||
|
| "manual";
|
||||||
|
|
||||||
|
export interface CoursePlanMedia {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
week_id: number | null;
|
||||||
|
material_id: number | null;
|
||||||
|
kind: CoursePlanMediaKind;
|
||||||
|
provider: CoursePlanMediaProvider | string;
|
||||||
|
title: string;
|
||||||
|
voice: string;
|
||||||
|
language: string;
|
||||||
|
style: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
duration_seconds: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
attachment_id: number | null;
|
||||||
|
download_url: string;
|
||||||
|
status: CoursePlanMediaStatus;
|
||||||
|
error: string;
|
||||||
|
cost_cents: number;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase D — Assignments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CoursePlanAssignmentMode = "batch" | "students";
|
||||||
|
export type CoursePlanAssignmentState = "active" | "archived";
|
||||||
|
|
||||||
|
export interface CoursePlanAssignment {
|
||||||
|
id: number;
|
||||||
|
plan_id: number;
|
||||||
|
plan_name: string;
|
||||||
|
mode: CoursePlanAssignmentMode;
|
||||||
|
batch_id: number | null;
|
||||||
|
batch_name: string;
|
||||||
|
student_user_ids: number[];
|
||||||
|
student_user_names: string[];
|
||||||
|
student_count: number;
|
||||||
|
assigned_by_id: number | null;
|
||||||
|
assigned_by_name: string;
|
||||||
|
due_date: string | null;
|
||||||
|
message: string;
|
||||||
|
state: CoursePlanAssignmentState;
|
||||||
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoursePlan {
|
export interface CoursePlan {
|
||||||
@@ -101,9 +237,17 @@ export interface CoursePlan {
|
|||||||
resources: CoursePlanResource[];
|
resources: CoursePlanResource[];
|
||||||
week_count: number;
|
week_count: number;
|
||||||
material_count: number;
|
material_count: number;
|
||||||
|
source_count?: number;
|
||||||
|
media_count?: number;
|
||||||
|
assignment_count?: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
weeks?: CoursePlanWeek[];
|
weeks?: CoursePlanWeek[];
|
||||||
materials?: CoursePlanMaterial[];
|
materials?: CoursePlanMaterial[];
|
||||||
|
sources?: CoursePlanSource[];
|
||||||
|
media?: CoursePlanMedia[];
|
||||||
|
assignments?: CoursePlanAssignment[];
|
||||||
|
/** Present on the student-list endpoint payload. */
|
||||||
|
assignment?: CoursePlanAssignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoursePlanGenerateBrief {
|
export interface CoursePlanGenerateBrief {
|
||||||
|
|||||||
Reference in New Issue
Block a user