Compare commits
1 Commits
main
...
ca4f12a1a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4f12a1a9 |
@@ -133,8 +133,6 @@ 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"));
|
||||||
@@ -271,8 +269,6 @@ 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, Sparkles,
|
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +17,6 @@ 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,7 +60,6 @@ const ar: Translations = {
|
|||||||
dashboard: "لوحة التحكم",
|
dashboard: "لوحة التحكم",
|
||||||
courses: "الدورات",
|
courses: "الدورات",
|
||||||
myCourses: "دوراتي",
|
myCourses: "دوراتي",
|
||||||
myCoursePlans: "خططي الدراسية",
|
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
batches: "الدفعات",
|
batches: "الدفعات",
|
||||||
@@ -740,14 +739,8 @@ const ar: Translations = {
|
|||||||
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
|
||||||
coverage: "التغطية",
|
coverage: "التغطية",
|
||||||
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
|
||||||
sources: "المصادر المرجعية",
|
|
||||||
sourcesDesc:
|
|
||||||
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
|
|
||||||
scope: "النطاق",
|
scope: "النطاق",
|
||||||
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
|
||||||
media: "الوسائط",
|
|
||||||
mediaDesc:
|
|
||||||
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
|
|
||||||
review: "المراجعة",
|
review: "المراجعة",
|
||||||
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
reviewDesc: "راجع البيانات قبل بدء التوليد.",
|
||||||
},
|
},
|
||||||
@@ -774,155 +767,7 @@ 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,7 +105,6 @@ 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",
|
||||||
@@ -797,14 +796,8 @@ 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.",
|
||||||
},
|
},
|
||||||
@@ -832,156 +825,7 @@ 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,19 +1,9 @@
|
|||||||
import { useRef, useState } from "react";
|
import { 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 {
|
import { X } from "lucide-react";
|
||||||
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";
|
||||||
@@ -48,33 +38,6 @@ 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;
|
||||||
@@ -85,8 +48,6 @@ 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 = [
|
||||||
@@ -222,17 +183,6 @@ 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",
|
||||||
@@ -264,17 +214,6 @@ 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",
|
||||||
@@ -318,26 +257,6 @@ 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>
|
||||||
@@ -364,11 +283,9 @@ 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) => {
|
||||||
const resp = await mutation.mutateAsync({
|
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,
|
||||||
@@ -381,284 +298,11 @@ 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">
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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,13 +1,8 @@
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,9 +12,6 @@ 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;
|
||||||
@@ -53,166 +45,4 @@ 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,142 +81,6 @@ 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 {
|
||||||
@@ -237,17 +101,9 @@ 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