{materials.map((m) => (
@@ -398,26 +1176,261 @@ function WeekAccordionItem({
function MaterialCard({ material }: { material: CoursePlanMaterial }) {
const { t } = useTranslation();
+ const [drawerOpen, setDrawerOpen] = useState(false);
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
+ const mediaCount = material.media?.length ?? 0;
return (
- {material.title}
+
+ {material.title}
+
- {t(`coursePlan.materialType.${material.material_type}`, material.material_type)}
+ {t(
+ `coursePlan.materialType.${material.material_type}`,
+ material.material_type,
+ )}
+
- {material.summary && (
- {material.summary}
- )}
+ {material.summary && {material.summary}}
{material.body_text || JSON.stringify(material.body, null, 2)}
+
+
);
}
+
+function MediaDrawer({
+ open,
+ onOpenChange,
+ material,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ material: CoursePlanMaterial;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+
+ const mediaQ = useQuery({
+ queryKey: ["material-media", material.id],
+ queryFn: () => coursePlanService.listMaterialMedia(material.id),
+ enabled: open,
+ initialData: material.media
+ ? { items: material.media, count: material.media.length }
+ : undefined,
+ });
+
+ const refresh = () => {
+ qc.invalidateQueries({ queryKey: ["material-media", material.id] });
+ qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
+ qc.invalidateQueries({
+ queryKey: ["course-plan", material.plan_id, "deliverables"],
+ });
+ };
+
+ const audioMut = useMutation({
+ mutationFn: () => coursePlanService.generateAudio(material.id),
+ onSuccess: () => {
+ toast.success(t("coursePlan.media.audioReady"));
+ refresh();
+ },
+ onError: (err) =>
+ toast.error(describeApiError(err, t("coursePlan.media.audioFailed"))),
+ });
+ const imageMut = useMutation({
+ mutationFn: () => coursePlanService.generateImage(material.id),
+ onSuccess: () => {
+ toast.success(t("coursePlan.media.imageReady"));
+ refresh();
+ },
+ onError: (err) =>
+ toast.error(describeApiError(err, t("coursePlan.media.imageFailed"))),
+ });
+ const videoMut = useMutation({
+ mutationFn: () => coursePlanService.composeVideo(material.id),
+ onSuccess: () => {
+ toast.success(t("coursePlan.media.videoReady"));
+ refresh();
+ },
+ onError: (err) =>
+ toast.error(describeApiError(err, t("coursePlan.media.videoFailed"))),
+ });
+ const deleteMut = useMutation({
+ mutationFn: (mid: number) => coursePlanService.deleteMedia(mid),
+ onSuccess: () => {
+ toast.success(t("coursePlan.media.deleted"));
+ refresh();
+ },
+ onError: (err) =>
+ toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
+ });
+
+ const items = mediaQ.data?.items ?? [];
+
+ return (
+
+
+
+
+ {t("coursePlan.media.drawerTitle", { title: material.title })}
+
+
+ {t("coursePlan.media.drawerSubtitle")}
+
+
+
+
+
+
+
+
+
+
+ {items.length === 0 && (
+
+ {t("coursePlan.media.noMedia")}
+
+ )}
+ {items.map((m) => (
+
{
+ if (!window.confirm(t("common.confirm", "Are you sure?"))) return;
+ deleteMut.mutate(m.id);
+ }}
+ />
+ ))}
+
+
+
+ );
+}
+
+function MediaTile({
+ media,
+ onDelete,
+}: {
+ media: CoursePlanMedia;
+ onDelete: () => void;
+}) {
+ const { t } = useTranslation();
+ const url = media.download_url || "";
+ return (
+
+
+
+ {media.kind}
+
+
{media.provider}
+
+ {url && (
+
+ )}
+ {url && (
+
+ )}
+
+
+
+ {media.kind === "audio" && url && (
+
+ )}
+ {media.kind === "image" && url && (
+

+ )}
+ {media.kind === "video" && url && (
+
+ )}
+ {media.status === "failed" && media.error && (
+
{media.error}
+ )}
+ {media.status !== "ready" && media.status !== "failed" && (
+
+
+ {t("coursePlan.media.generating")}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/admin/wizards/CoursePlanWizard.tsx b/frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
index dbfd4336..42104a9d 100644
--- a/frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
+++ b/frontend/src/pages/admin/wizards/CoursePlanWizard.tsx
@@ -1,9 +1,19 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
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 { Input } from "@/components/ui/input";
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
* 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 {
title: string;
cefr_level: string;
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
grammar_focus: string[];
resources: string[];
notes: string;
+ sources: DraftSource[];
+ media: MediaToggleState;
}
const CEFR_OPTIONS = [
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
),
},
+ {
+ id: "sources",
+ titleKey: "coursePlan.wizard.steps.sources",
+ descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
+ render: ({ state, update }) => (
+