fix(admin): clickable lesson publish chips and exam-prep status APIs

Wire exam-prep and Learn English publish-status PUT helpers, load real catalog metadata on detail pages, and make lesson card publish chips interactive via ContentPublishStatusChip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-10 05:58:19 -07:00
parent 39312bf509
commit a10d7684d5
9 changed files with 295 additions and 132 deletions

View File

@ -616,11 +616,17 @@ export const updateExamPrepModuleLesson = (
data, data,
) )
/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */ /** PUT /exam-prep/lessons/:lessonId — set publish_status only. */
export const setExamPrepModuleLessonPublishStatus = (
lessonId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** @deprecated Use setExamPrepModuleLessonPublishStatus */
export const publishExamPrepModuleLesson = ( export const publishExamPrepModuleLesson = (
lessonId: number, lessonId: number,
data: PublishExamPrepModuleLessonRequest, data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data) ) => setExamPrepModuleLessonPublishStatus(lessonId, data)
/** PUT /exam-prep/lessons/:lessonId — set access_tier only. */ /** PUT /exam-prep/lessons/:lessonId — set access_tier only. */
export const setExamPrepModuleLessonAccessTier = ( export const setExamPrepModuleLessonAccessTier = (
@ -840,16 +846,22 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest, data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data) ) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — set publish_status only. */ /** PUT /practices/:id — set publish_status only (Learn English practice). */
export const setParentLinkedPracticePublishStatus = ( export const setLearnEnglishPracticePublishStatus = (
practiceId: number, practiceId: number,
data: PublishParentLinkedPracticeRequest, data: PublishStatusOnlyRequest,
) => ) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data) http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** @deprecated Use setLearnEnglishPracticePublishStatus */
export const setParentLinkedPracticePublishStatus = (
practiceId: number,
data: PublishParentLinkedPracticeRequest,
) => setLearnEnglishPracticePublishStatus(practiceId, data)
/** PUT /practices/:id — publish a draft practice. */ /** PUT /practices/:id — publish a draft practice. */
export const publishParentLinkedPractice = (practiceId: number) => export const publishParentLinkedPractice = (practiceId: number) =>
setParentLinkedPracticePublishStatus(practiceId, { setLearnEnglishPracticePublishStatus(practiceId, {
publish_status: "PUBLISHED", publish_status: "PUBLISHED",
}) })

View File

@ -29,7 +29,7 @@ import {
getPracticesByParentCourse, getPracticesByParentCourse,
getProgramCourses, getProgramCourses,
getTopLevelCourseModules, getTopLevelCourseModules,
setParentLinkedPracticePublishStatus, setLearnEnglishPracticePublishStatus,
setTopLevelCourseModuleAccessTier, setTopLevelCourseModuleAccessTier,
setTopLevelCourseModulePublishStatus, setTopLevelCourseModulePublishStatus,
updateTopLevelCourseModule, updateTopLevelCourseModule,
@ -359,7 +359,7 @@ export function CourseDetailPage() {
) => { ) => {
setPublishStatusPracticeId(practiceId); setPublishStatusPracticeId(practiceId);
try { try {
await setParentLinkedPracticePublishStatus(practiceId, { await setLearnEnglishPracticePublishStatus(practiceId, {
publish_status: nextStatus, publish_status: nextStatus,
}); });
setPractices((prev) => setPractices((prev) =>

View File

@ -28,6 +28,8 @@ import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage"; import { ResolvedImage } from "../../components/media/ResolvedImage";
import { import {
createExamPrepCatalogUnit, createExamPrepCatalogUnit,
getExamPrepCatalogCourses,
setExamPrepCatalogCoursePublishStatus,
setExamPrepCatalogUnitAccessTier, setExamPrepCatalogUnitAccessTier,
setExamPrepCatalogUnitPublishStatus, setExamPrepCatalogUnitPublishStatus,
updateExamPrepCatalogUnit, updateExamPrepCatalogUnit,
@ -38,6 +40,7 @@ import { uploadImageFile } from "../../api/files.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import { import {
filterBySearchAndPublishStatus, filterBySearchAndPublishStatus,
@ -92,6 +95,13 @@ export function CourseManagementPage() {
const [listSearch, setListSearch] = useState(""); const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] = const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all"); useState<PublishStatusFilter>("all");
const [catalogCourseName, setCatalogCourseName] = useState("Course");
const [catalogCourseDescription, setCatalogCourseDescription] = useState("");
const [catalogCoursePublishStatus, setCatalogCoursePublishStatus] = useState<
PracticePublishStatus | string | null
>(null);
const [catalogCoursePublishStatusUpdating, setCatalogCoursePublishStatusUpdating] =
useState(false);
const filteredUnits = useMemo( const filteredUnits = useMemo(
() => () =>
@ -104,14 +114,24 @@ export function CourseManagementPage() {
[listSearch, publishStatusFilter, units], [listSearch, publishStatusFilter, units],
); );
// Mock data for display titles const courseDisplayName = catalogCourseName;
const courseTitles: Record<string, string> = {
duolingo: "Duolingo English Test",
ielts: "IELTS Academic",
};
const courseDisplayName = const loadCatalogCourse = useCallback(async () => {
courseTitles[courseId || ""] || "Duolingo English Test"; if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) return;
try {
const response = await getExamPrepCatalogCourses({ limit: 100, offset: 0 });
const rows = response.data?.data?.catalog_courses;
const list = Array.isArray(rows) ? rows : [];
const row = list.find((c) => Number(c.id) === catalogCourseId);
if (row) {
setCatalogCourseName(row.name?.trim() || `Course ${catalogCourseId}`);
setCatalogCourseDescription(row.description?.trim() || "");
setCatalogCoursePublishStatus(row.publish_status ?? null);
}
} catch (error) {
console.error(error);
}
}, [catalogCourseId]);
const loadUnits = useCallback(async () => { const loadUnits = useCallback(async () => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) { if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) {
@ -155,10 +175,37 @@ export function CourseManagementPage() {
} }
}, [catalogCourseId]); }, [catalogCourseId]);
useEffect(() => {
void loadCatalogCourse();
}, [loadCatalogCourse]);
useEffect(() => { useEffect(() => {
void loadUnits(); void loadUnits();
}, [loadUnits]); }, [loadUnits]);
const handleCatalogCoursePublishStatus = async (
nextStatus: PracticePublishStatus,
) => {
if (!Number.isFinite(catalogCourseId) || catalogCourseId < 1) return;
setCatalogCoursePublishStatusUpdating(true);
try {
await setExamPrepCatalogCoursePublishStatus(catalogCourseId, {
publish_status: nextStatus,
});
setCatalogCoursePublishStatus(nextStatus);
toast.success(
nextStatus === "PUBLISHED" ? "Course published" : "Course saved as draft",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course status";
toast.error(message);
} finally {
setCatalogCoursePublishStatusUpdating(false);
}
};
const handleUnitPublishStatus = async ( const handleUnitPublishStatus = async (
unitId: number, unitId: number,
nextStatus: PracticePublishStatus, nextStatus: PracticePublishStatus,
@ -484,12 +531,28 @@ export function CourseManagementPage() {
{/* Header section */} {/* Header section */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<ContentPublishStatusChip
publishStatus={catalogCoursePublishStatus}
updating={catalogCoursePublishStatusUpdating}
contentLabel="course"
onToggle={(nextStatus) =>
void handleCatalogCoursePublishStatus(nextStatus)
}
/>
</div>
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900"> <h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
{courseDisplayName} {courseDisplayName}
</h1> </h1>
{catalogCourseDescription ? (
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
{catalogCourseDescription}
</ContentPageDescription>
) : (
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500"> <p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
Manage units and modules inside the {courseDisplayName} Manage units and modules inside {courseDisplayName}
</p> </p>
)}
</div> </div>
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">

View File

@ -23,8 +23,10 @@ import {
updateExamPrepModuleLesson, updateExamPrepModuleLesson,
deleteExamPrepModuleLesson, deleteExamPrepModuleLesson,
getExamPrepModuleLessons, getExamPrepModuleLessons,
publishExamPrepModuleLesson, getExamPrepUnitModules,
setExamPrepModuleLessonAccessTier, setExamPrepModuleLessonAccessTier,
setExamPrepModuleLessonPublishStatus,
setExamPrepUnitModulePublishStatus,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api"; import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview"; import { resolveThumbnailForPreview } from "../../lib/videoPreview";
@ -34,6 +36,7 @@ import type {
} from "../../types/course.types"; } from "../../types/course.types";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription"; import { ContentPageDescription } from "./components/ContentPageDescription";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { import {
filterBySearchAndPublishStatus, filterBySearchAndPublishStatus,
type PublishStatusFilter, type PublishStatusFilter,
@ -72,8 +75,16 @@ export function CourseModuleDetailPage() {
moduleId: string; moduleId: string;
}>(); }>();
const parsedModuleId = Number(moduleId); const parsedModuleId = Number(moduleId);
const parsedUnitId = Number(unitId);
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [moduleTitle, setModuleTitle] = useState("Module");
const [moduleDescription, setModuleDescription] = useState("—");
const [modulePublishStatus, setModulePublishStatus] = useState<
PracticePublishStatus | string | null
>(null);
const [modulePublishStatusUpdating, setModulePublishStatusUpdating] =
useState(false);
const [lessonsLoading, setLessonsLoading] = useState(false); const [lessonsLoading, setLessonsLoading] = useState(false);
const [lessons, setLessons] = useState< const [lessons, setLessons] = useState<
Array<{ Array<{
@ -133,8 +144,42 @@ export function CourseModuleDetailPage() {
const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null); const [deletingLessonId, setDeletingLessonId] = useState<number | null>(null);
const [deletingLesson, setDeletingLesson] = useState(false); const [deletingLesson, setDeletingLesson] = useState(false);
const moduleTitle = "Module 1: Basic Phrases"; const loadModule = useCallback(async () => {
const moduleDescription = "Learn essential phrases for daily conversations."; if (
!Number.isFinite(parsedUnitId) ||
parsedUnitId < 1 ||
!Number.isFinite(parsedModuleId) ||
parsedModuleId < 1
) {
return;
}
try {
const response = await getExamPrepUnitModules(parsedUnitId, {
limit: 100,
offset: 0,
});
const rows = response.data?.data?.modules;
const list = Array.isArray(rows) ? rows : [];
const row = list.find((m) => Number(m.id) === parsedModuleId);
if (row) {
setModuleTitle(row.name?.trim() || `Module ${parsedModuleId}`);
setModuleDescription(row.description?.trim() || "—");
setModulePublishStatus(row.publish_status ?? null);
} else {
setModuleTitle(`Module ${parsedModuleId}`);
setModuleDescription("—");
setModulePublishStatus(null);
}
} catch (error) {
console.error(error);
setModuleTitle(`Module ${parsedModuleId}`);
setModuleDescription("—");
}
}, [parsedModuleId, parsedUnitId]);
useEffect(() => {
void loadModule();
}, [loadModule]);
const isHttpUrl = (value: string) => const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://"); value.startsWith("http://") || value.startsWith("https://");
@ -501,13 +546,37 @@ export function CourseModuleDetailPage() {
} }
}; };
const handleModulePublishStatus = async (nextStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) return;
setModulePublishStatusUpdating(true);
try {
await setExamPrepUnitModulePublishStatus(parsedModuleId, {
publish_status: nextStatus,
});
setModulePublishStatus(nextStatus);
toast.success(
nextStatus === "PUBLISHED"
? "Module published"
: "Module saved as draft",
);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update module status";
toast.error(message);
} finally {
setModulePublishStatusUpdating(false);
}
};
const handleToggleLessonPublishStatus = async ( const handleToggleLessonPublishStatus = async (
lessonId: number, lessonId: number,
nextStatus: PracticePublishStatus, nextStatus: PracticePublishStatus,
) => { ) => {
setPublishStatusLessonId(lessonId); setPublishStatusLessonId(lessonId);
try { try {
await publishExamPrepModuleLesson(lessonId, { await setExamPrepModuleLessonPublishStatus(lessonId, {
publish_status: nextStatus, publish_status: nextStatus,
}); });
setLessons((prev) => setLessons((prev) =>
@ -582,6 +651,14 @@ export function CourseModuleDetailPage() {
{/* Header section */} {/* Header section */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<ContentPublishStatusChip
publishStatus={modulePublishStatus}
updating={modulePublishStatusUpdating}
contentLabel="module"
onToggle={(nextStatus) => void handleModulePublishStatus(nextStatus)}
/>
</div>
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]"> <h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
{moduleTitle} {moduleTitle}
</h1> </h1>

View File

@ -18,7 +18,7 @@ import {
getExamPrepLessonPractices, getExamPrepLessonPractices,
getPracticesByParentLesson, getPracticesByParentLesson,
setExamPrepPracticePublishStatus, setExamPrepPracticePublishStatus,
setParentLinkedPracticePublishStatus, setLearnEnglishPracticePublishStatus,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@ -332,7 +332,7 @@ export function LessonPracticesPage() {
publish_status: nextStatus, publish_status: nextStatus,
}); });
} else { } else {
await setParentLinkedPracticePublishStatus(practiceId, { await setLearnEnglishPracticePublishStatus(practiceId, {
publish_status: nextStatus, publish_status: nextStatus,
}); });
} }

View File

@ -8,7 +8,7 @@ import {
getPracticesByParentModule, getPracticesByParentModule,
getTopLevelCourseModules, getTopLevelCourseModules,
publishTopLevelModuleLesson, publishTopLevelModuleLesson,
setParentLinkedPracticePublishStatus, setLearnEnglishPracticePublishStatus,
setTopLevelModuleLessonAccessTier, setTopLevelModuleLessonAccessTier,
updateTopLevelModuleLesson, updateTopLevelModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
@ -290,7 +290,7 @@ export function ModuleDetailPage() {
) => { ) => {
setPublishStatusPracticeId(practiceId); setPublishStatusPracticeId(practiceId);
try { try {
await setParentLinkedPracticePublishStatus(practiceId, { await setLearnEnglishPracticePublishStatus(practiceId, {
publish_status: nextStatus, publish_status: nextStatus,
}); });
setPractices((prev) => setPractices((prev) =>

View File

@ -27,7 +27,9 @@ import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage"; import { ResolvedImage } from "../../components/media/ResolvedImage";
import { import {
createExamPrepUnitModule, createExamPrepUnitModule,
getExamPrepCatalogUnits,
getExamPrepUnitModules, getExamPrepUnitModules,
setExamPrepCatalogUnitPublishStatus,
setExamPrepUnitModuleAccessTier, setExamPrepUnitModuleAccessTier,
setExamPrepUnitModulePublishStatus, setExamPrepUnitModulePublishStatus,
updateExamPrepUnitModule, updateExamPrepUnitModule,
@ -37,6 +39,7 @@ import { uploadImageFile } from "../../api/files.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip"; import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip"; import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar"; import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types"; import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import { import {
filterBySearchAndPublishStatus, filterBySearchAndPublishStatus,
@ -51,17 +54,15 @@ export function UnitManagementPage() {
unitId: string; unitId: string;
}>(); }>();
// Mock titles
const unitTitles: Record<string, string> = {
unit1: "Greetings & Introductions",
unit2: "Speaking",
unit3: "Reading",
};
const unitDisplayName =
unitTitles[unitId || ""] || "Greetings & Introductions";
const parsedUnitId = Number(unitId); const parsedUnitId = Number(unitId);
const catalogCourseId = Number(courseId);
const [unitDisplayName, setUnitDisplayName] = useState("Unit");
const [unitDescription, setUnitDescription] = useState("");
const [unitPublishStatus, setUnitPublishStatus] = useState<
PracticePublishStatus | string | null
>(null);
const [unitPublishStatusUpdating, setUnitPublishStatusUpdating] =
useState(false);
const [addModuleOpen, setAddModuleOpen] = useState(false); const [addModuleOpen, setAddModuleOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
@ -142,6 +143,38 @@ export function UnitManagementPage() {
return uploadedUrl; return uploadedUrl;
}; };
const loadUnit = useCallback(async () => {
if (
!Number.isFinite(catalogCourseId) ||
catalogCourseId < 1 ||
!Number.isFinite(parsedUnitId) ||
parsedUnitId < 1
) {
return;
}
try {
const response = await getExamPrepCatalogUnits(catalogCourseId, {
limit: 100,
offset: 0,
});
const rows = response.data?.data?.units;
const list = Array.isArray(rows) ? rows : [];
const row = list.find((u) => Number(u.id) === parsedUnitId);
if (row) {
setUnitDisplayName(row.name?.trim() || `Unit ${parsedUnitId}`);
setUnitDescription(row.description?.trim() || "");
setUnitPublishStatus(row.publish_status ?? null);
} else {
setUnitDisplayName(`Unit ${parsedUnitId}`);
setUnitDescription("");
setUnitPublishStatus(null);
}
} catch (error) {
console.error(error);
setUnitDisplayName(`Unit ${parsedUnitId}`);
}
}, [catalogCourseId, parsedUnitId]);
const loadModules = useCallback(async () => { const loadModules = useCallback(async () => {
if (!Number.isFinite(parsedUnitId) || parsedUnitId < 1) { if (!Number.isFinite(parsedUnitId) || parsedUnitId < 1) {
setModules([]); setModules([]);
@ -184,10 +217,35 @@ export function UnitManagementPage() {
} }
}, [parsedUnitId]); }, [parsedUnitId]);
useEffect(() => {
void loadUnit();
}, [loadUnit]);
useEffect(() => { useEffect(() => {
void loadModules(); void loadModules();
}, [loadModules]); }, [loadModules]);
const handleUnitPublishStatus = async (nextStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedUnitId) || parsedUnitId < 1) return;
setUnitPublishStatusUpdating(true);
try {
await setExamPrepCatalogUnitPublishStatus(parsedUnitId, {
publish_status: nextStatus,
});
setUnitPublishStatus(nextStatus);
toast.success(
nextStatus === "PUBLISHED" ? "Unit published" : "Unit saved as draft",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update unit status";
toast.error(message);
} finally {
setUnitPublishStatusUpdating(false);
}
};
const handleModulePublishStatus = async ( const handleModulePublishStatus = async (
moduleId: number, moduleId: number,
nextStatus: PracticePublishStatus, nextStatus: PracticePublishStatus,
@ -530,10 +588,25 @@ export function UnitManagementPage() {
</Link> </Link>
{/* Header section */} {/* Header section */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-6">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<ContentPublishStatusChip
publishStatus={unitPublishStatus}
updating={unitPublishStatusUpdating}
contentLabel="unit"
onToggle={(nextStatus) => void handleUnitPublishStatus(nextStatus)}
/>
</div>
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900"> <h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
{unitDisplayName} {unitDisplayName}
</h1> </h1>
{unitDescription ? (
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
{unitDescription}
</ContentPageDescription>
) : null}
</div>
<Dialog <Dialog
open={addModuleOpen} open={addModuleOpen}

View File

@ -4,8 +4,7 @@ import { toast } from "sonner"
import { import {
getPracticesByParentCourse, getPracticesByParentCourse,
getPracticesByParentModule, getPracticesByParentModule,
publishParentLinkedPractice, setLearnEnglishPracticePublishStatus,
updateParentLinkedPractice,
} from "../../../api/courses.api" } from "../../../api/courses.api"
import type { PracticeParentKind } from "../../../types/course.types" import type { PracticeParentKind } from "../../../types/course.types"
import { Button } from "../../../components/ui/button" import { Button } from "../../../components/ui/button"
@ -86,7 +85,9 @@ export function PublishPracticeButton({
return return
} }
for (const practice of drafts) { for (const practice of drafts) {
await publishParentLinkedPractice(practice.id) await setLearnEnglishPracticePublishStatus(practice.id, {
publish_status: "PUBLISHED",
})
} }
toast.success( toast.success(
drafts.length === 1 drafts.length === 1
@ -120,7 +121,7 @@ export function PublishPracticeButton({
return return
} }
for (const practice of toDraft) { for (const practice of toDraft) {
await updateParentLinkedPractice(practice.id, { await setLearnEnglishPracticePublishStatus(practice.id, {
publish_status: "DRAFT", publish_status: "DRAFT",
}) })
} }

View File

@ -3,19 +3,12 @@ import {
BookOpen, BookOpen,
Calendar, Calendar,
Edit2, Edit2,
Loader2,
MoreVertical, MoreVertical,
Pencil, Pencil,
Play, Play,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -33,12 +26,12 @@ import {
isDirectVideoFileUrl, isDirectVideoFileUrl,
} from "../../../lib/videoPreview"; } from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo"; import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
import type { import type {
ContentAccessTier, ContentAccessTier,
PracticePublishStatus, PracticePublishStatus,
} from "../../../types/course.types"; } from "../../../types/course.types";
import { ContentAccessTierChip } from "./ContentAccessTierChip"; import { ContentAccessTierChip } from "./ContentAccessTierChip";
import { ContentPublishStatusChip } from "./ContentPublishStatusChip";
function resolvePublishBadge( function resolvePublishBadge(
publishStatus?: PracticePublishStatus | string | null, publishStatus?: PracticePublishStatus | string | null,
@ -129,9 +122,6 @@ export function VideoCard({
number | null number | null
>(null); >(null);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [pendingPublishStatus, setPendingPublishStatus] =
useState<PracticePublishStatus | null>(null);
/** Iframe players ignore URL limits in many cases — unmount after real time. */ /** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false); const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0); const [iframeSessionKey, setIframeSessionKey] = useState(0);
@ -152,23 +142,6 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength( const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS, DEFAULT_PREVIEW_MAX_SECONDS,
); );
const requestPublishStatusChange = (
nextStatus: PracticePublishStatus,
e?: React.MouseEvent,
) => {
e?.stopPropagation();
if (publishStatusUpdating) return;
setPendingPublishStatus(nextStatus);
setPublishConfirmOpen(true);
};
const confirmPublishStatusChange = () => {
if (!pendingPublishStatus || !onTogglePublishStatus) return;
onTogglePublishStatus(pendingPublishStatus);
setPublishConfirmOpen(false);
setPendingPublishStatus(null);
};
const publishBadge = resolvePublishBadge( const publishBadge = resolvePublishBadge(
publishStatus, publishStatus,
status, status,
@ -487,6 +460,15 @@ export function VideoCard({
> >
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
{publishBadge ? ( {publishBadge ? (
onTogglePublishStatus ? (
<ContentPublishStatusChip
publishStatus={publishStatus ?? publishBadge.label}
updating={publishStatusUpdating}
contentLabel="lesson"
onToggle={onTogglePublishStatus}
className="text-[11px]"
/>
) : (
<div <div
className={cn( className={cn(
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider", "flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
@ -503,6 +485,7 @@ export function VideoCard({
/> />
{publishBadge.label} {publishBadge.label}
</div> </div>
)
) : ( ) : (
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500"> <div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" /> <div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
@ -518,42 +501,7 @@ export function VideoCard({
/> />
) : null} ) : null}
</div> </div>
{hoverModuleActions && onTogglePublishStatus ? ( {!hoverModuleActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
disabled={publishStatusUpdating || accessTierUpdating}
aria-label={`Lesson options: ${title}`}
onClick={(e) => e.stopPropagation()}
>
{publishStatusUpdating || accessTierUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<MoreVertical className="h-5 w-5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={publishStatusUpdating || accessTierUpdating}
onClick={(e) => {
requestPublishStatusChange(
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
e,
);
}}
>
{publishBadge?.isPublished
? "Save as draft"
: "Publish lesson"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : !hoverModuleActions ? (
<button <button
type="button" type="button"
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400" className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
@ -619,17 +567,6 @@ export function VideoCard({
) : null} ) : null}
</div> </div>
</div> </div>
<PublishStatusConfirmDialog
open={publishConfirmOpen}
onOpenChange={(open) => {
setPublishConfirmOpen(open);
if (!open) setPendingPublishStatus(null);
}}
nextStatus={pendingPublishStatus}
contentLabel="lesson"
confirming={publishStatusUpdating}
onConfirm={confirmPublishStatusChange}
/>
</> </>
); );
} }