Yimaru-Admin/src/pages/content-management/LessonPracticesPage.tsx
Yared Yemane 2b556d9d09 feat(content): lesson practices page, dynamic question schema, and practice flow updates
- Add LessonPracticesPage with GET /lessons/:id/practices and polished UI
- Route and module lesson navigation; view practices icon on VideoCard hover
- Question type definitions API, DynamicSchemaSlotField, definition helpers
- AddPracticeFlow and practice steps; AddQuestionPage and PracticeQuestionEditorFields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:30:53 -07:00

380 lines
16 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
ArrowLeft,
BookOpen,
Calendar,
Clock,
Hash,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { getPracticesByParentLesson } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
} from "../../types/course.types";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
function unwrapPracticesEnvelope(
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
): GetPracticesByParentContextResponse["data"] | null {
const b = res.data;
if (!b) return null;
return b.data ?? b.Data ?? null;
}
function formatPracticeDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
function PracticeCard({
practice,
index,
total,
}: {
practice: ParentContextPractice;
index: number;
total: number;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
const showThumb = Boolean(thumb) && !imgFailed;
return (
<Card
className={cn(
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
)}
>
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row lg:items-stretch">
<div className="relative shrink-0 lg:w-[280px]">
<div
className={cn(
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
)}
>
{showThumb ? (
<>
<img
src={thumb!}
alt=""
className="h-full w-full object-cover"
onError={() => setImgFailed(true)}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
</>
) : (
<div className="flex flex-col items-center gap-2 text-grayScale-400">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
<BookOpen className="h-7 w-7" />
</div>
<span className="text-[11px] font-semibold uppercase tracking-wider">
No cover image
</span>
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
</div>
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
{practice.title}
</h2>
{practice.story_description?.trim() ? (
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
Story & instructions
</p>
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
{practice.story_description}
</p>
</div>
) : null}
{practice.quick_tips?.trim() ? (
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
Quick tips
</p>
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
{practice.quick_tips}
</p>
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Hash className="h-3 w-3 opacity-70" aria-hidden />
Question set {practice.question_set_id}
</Badge>
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Clock className="h-3 w-3 opacity-70" aria-hidden />
{formatPracticeDate(practice.created_at)}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
export function LessonPracticesPage() {
const navigate = useNavigate();
const { level, courseId, moduleId, lessonId } = useParams<{
level: string;
courseId: string;
moduleId: string;
lessonId: string;
}>();
const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const backHref = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
const load = useCallback(async () => {
if (!validLesson) {
setLoading(false);
setLoadError("Invalid lesson.");
setPractices([]);
return;
}
setLoading(true);
setLoadError(null);
try {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
} catch {
setPractices([]);
setTotalCount(0);
setLoadError("Could not load practices for this lesson.");
toast.error("Failed to load practices");
} finally {
setLoading(false);
}
}, [lid, validLesson]);
useEffect(() => {
void load();
}, [load]);
const displayTitle =
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
const addPracticeHref = `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
</div>
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
<Link
to={backHref}
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
<ArrowLeft className="h-4 w-4" />
</span>
Back to module
</Link>
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
Lesson practices
</p>
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
{displayTitle}
</h1>
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
Review speaking practices linked to this lesson. Thumbnails
and copy come from your published practice content.
</p>
{!loading && !loadError ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
{practices.length}{" "}
{practices.length === 1 ? "practice" : "practices"}
</Badge>
{totalCount > practices.length ? (
<span className="text-[12px] text-grayScale-500">
Showing {practices.length} of {totalCount}
</span>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Add practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
<div className="text-center">
<p className="text-[16px] font-semibold text-grayScale-800">
Loading practices
</p>
<p className="mt-1 text-[14px] text-grayScale-500">
Fetching content for this lesson
</p>
</div>
</CardContent>
</Card>
) : loadError ? (
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle className="h-8 w-8" />
</div>
<div>
<p className="text-lg font-semibold text-grayScale-900">
Something went wrong
</p>
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
{loadError}
</p>
</div>
<Button
type="button"
variant="outline"
className="rounded-xl border-grayScale-300 font-semibold"
onClick={() => void load()}
>
Try again
</Button>
</CardContent>
</Card>
) : practices.length === 0 ? (
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
</div>
<p className="text-xl font-semibold text-grayScale-900">
No practices yet
</p>
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
This lesson does not have any linked practices. Create one to
give learners a structured speaking activity after the video.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Create practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
asChild
>
<Link to={backHref}>Return to module</Link>
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{practices.map((p, i) => (
<PracticeCard
key={p.id}
practice={p}
index={i}
total={practices.length}
/>
))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div>
)}
</div>
</div>
</div>
);
}