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:
parent
39312bf509
commit
a10d7684d5
|
|
@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
|
{catalogCourseDescription ? (
|
||||||
Manage units and modules inside the {courseDisplayName}
|
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
|
||||||
</p>
|
{catalogCourseDescription}
|
||||||
|
</ContentPageDescription>
|
||||||
|
) : (
|
||||||
|
<p className="max-w-2xl text-[15px] font-medium leading-relaxed text-grayScale-500">
|
||||||
|
Manage units and modules inside {courseDisplayName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
<div className="space-y-2">
|
||||||
{unitDisplayName}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</h1>
|
<ContentPublishStatusChip
|
||||||
|
publishStatus={unitPublishStatus}
|
||||||
|
updating={unitPublishStatusUpdating}
|
||||||
|
contentLabel="unit"
|
||||||
|
onToggle={(nextStatus) => void handleUnitPublishStatus(nextStatus)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[28px] font-medium tracking-tight text-grayScale-900">
|
||||||
|
{unitDisplayName}
|
||||||
|
</h1>
|
||||||
|
{unitDescription ? (
|
||||||
|
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
|
||||||
|
{unitDescription}
|
||||||
|
</ContentPageDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={addModuleOpen}
|
open={addModuleOpen}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,22 +460,32 @@ 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 ? (
|
||||||
<div
|
onTogglePublishStatus ? (
|
||||||
className={cn(
|
<ContentPublishStatusChip
|
||||||
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
|
publishStatus={publishStatus ?? publishBadge.label}
|
||||||
publishBadge.isPublished
|
updating={publishStatusUpdating}
|
||||||
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
|
contentLabel="lesson"
|
||||||
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
|
onToggle={onTogglePublishStatus}
|
||||||
)}
|
className="text-[11px]"
|
||||||
>
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
|
||||||
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
publishBadge.isPublished
|
||||||
|
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
|
||||||
|
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
{publishBadge.label}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||||
|
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{publishBadge.label}
|
||||||
|
</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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user