feat(admin): content management publish/access controls, reorder, and team invites

Wire publish status and access tier toggles, list search/filtering, and hierarchy reorder APIs across content pages; switch team member adds to email invites and collapse long page descriptions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-10 05:34:33 -07:00
parent e0e5577ea8
commit 39312bf509
26 changed files with 2306 additions and 348 deletions

View File

@ -62,6 +62,7 @@ import type {
SubCourse,
GetSubCourseEntryAssessmentResponse,
ReorderItem,
ReorderOrderedIdsRequest,
GetRatingsResponse,
GetRatingsParams,
GetVimeoSampleResponse,
@ -109,6 +110,8 @@ import type {
UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
PublishStatusOnlyRequest,
AccessTierOnlyRequest,
UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest,
@ -468,6 +471,28 @@ export const getProgramCourses = (
params?: { limit?: number; offset?: number },
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
/** PUT /programs/reorder */
export const reorderLearningPrograms = (data: ReorderOrderedIdsRequest) =>
http.put("/programs/reorder", data)
/** PUT /programs/:programId/courses/reorder */
export const reorderProgramCourses = (
programId: number,
data: ReorderOrderedIdsRequest,
) => http.put(`/programs/${programId}/courses/reorder`, data)
/** PUT /courses/:courseId/modules/reorder */
export const reorderTopLevelCourseModules = (
courseId: number,
data: ReorderOrderedIdsRequest,
) => http.put(`/courses/${courseId}/modules/reorder`, data)
/** PUT /modules/:moduleId/lessons/reorder */
export const reorderModuleLessons = (
moduleId: number,
data: ReorderOrderedIdsRequest,
) => http.put(`/modules/${moduleId}/lessons/reorder`, data)
export const createProgramCourse = (
programId: number,
data: CreateProgramCourseRequest,
@ -597,6 +622,12 @@ export const publishExamPrepModuleLesson = (
data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** PUT /exam-prep/lessons/:lessonId — set access_tier only. */
export const setExamPrepModuleLessonAccessTier = (
lessonId: number,
data: AccessTierOnlyRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`)
@ -627,6 +658,84 @@ export const deleteExamPrepPractice = (practiceId: number) =>
`/exam-prep/practices/${practiceId}`,
)
/** PUT /exam-prep/practices/:practiceId — set publish_status only. */
export const setExamPrepPracticePublishStatus = (
practiceId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/exam-prep/practices/${practiceId}`, data)
/** PUT /programs/:programId — set publish_status only. */
export const setLearningProgramPublishStatus = (
programId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/programs/${programId}`, data)
/** PUT /programs/:programId — set access_tier only. */
export const setLearningProgramAccessTier = (
programId: number,
data: AccessTierOnlyRequest,
) => http.put(`/programs/${programId}`, data)
/** PUT /courses/:courseId — set publish_status only (program-linked course). */
export const setProgramCoursePublishStatus = (
courseId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/courses/${courseId}`, data)
/** PUT /courses/:courseId — set access_tier only. */
export const setProgramCourseAccessTier = (
courseId: number,
data: AccessTierOnlyRequest,
) => http.put(`/courses/${courseId}`, data)
/** PUT /exam-prep/catalog-courses/:catalogCourseId — set publish_status only. */
export const setExamPrepCatalogCoursePublishStatus = (
catalogCourseId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data)
/** PUT /exam-prep/catalog-courses/:catalogCourseId — set access_tier only. */
export const setExamPrepCatalogCourseAccessTier = (
catalogCourseId: number,
data: AccessTierOnlyRequest,
) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data)
/** PUT /exam-prep/units/:unitId — set publish_status only. */
export const setExamPrepCatalogUnitPublishStatus = (
unitId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/exam-prep/units/${unitId}`, data)
/** PUT /exam-prep/units/:unitId — set access_tier only. */
export const setExamPrepCatalogUnitAccessTier = (
unitId: number,
data: AccessTierOnlyRequest,
) => http.put(`/exam-prep/units/${unitId}`, data)
/** PUT /exam-prep/modules/:moduleId — set publish_status only. */
export const setExamPrepUnitModulePublishStatus = (
moduleId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/exam-prep/modules/${moduleId}`, data)
/** PUT /exam-prep/modules/:moduleId — set access_tier only. */
export const setExamPrepUnitModuleAccessTier = (
moduleId: number,
data: AccessTierOnlyRequest,
) => http.put(`/exam-prep/modules/${moduleId}`, data)
/** PUT /modules/:moduleId — set publish_status only (Learn English module). */
export const setTopLevelCourseModulePublishStatus = (
moduleId: number,
data: PublishStatusOnlyRequest,
) => http.put(`/modules/${moduleId}`, data)
/** PUT /modules/:moduleId — set access_tier only. */
export const setTopLevelCourseModuleAccessTier = (
moduleId: number,
data: AccessTierOnlyRequest,
) => http.put(`/modules/${moduleId}`, data)
/** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data)
@ -690,6 +799,12 @@ export const publishTopLevelModuleLesson = (
data: PublishTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** PUT /lessons/:id — set access_tier only. */
export const setTopLevelModuleLessonAccessTier = (
lessonId: number,
data: AccessTierOnlyRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`)
@ -725,11 +840,18 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
/** PUT /practices/:id — set publish_status only. */
export const setParentLinkedPracticePublishStatus = (
practiceId: number,
data: PublishParentLinkedPracticeRequest,
) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — publish a draft practice. */
export const publishParentLinkedPractice = (practiceId: number) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
setParentLinkedPracticePublishStatus(practiceId, {
publish_status: "PUBLISHED",
} satisfies PublishParentLinkedPracticeRequest)
})
/** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) =>

28
src/lib/accessTier.ts Normal file
View File

@ -0,0 +1,28 @@
import type { ContentAccessTier } from "../types/course.types"
export function normalizeAccessTier(
raw?: string | null,
): ContentAccessTier | null {
if (raw === "FREE" || raw === "PREMIUM") return raw
if (typeof raw === "string") {
const upper = raw.trim().toUpperCase()
if (upper === "FREE" || upper === "PREMIUM") {
return upper as ContentAccessTier
}
}
return null
}
export function isPremiumAccessTier(raw?: string | null): boolean {
return normalizeAccessTier(raw) === "PREMIUM"
}
export function accessTierLabel(raw?: string | null): string {
return normalizeAccessTier(raw) ?? "FREE"
}
export function nextAccessTier(
current?: string | null,
): ContentAccessTier {
return isPremiumAccessTier(current) ? "FREE" : "PREMIUM"
}

View File

@ -0,0 +1,49 @@
import type { PracticePublishStatus } from "../types/course.types"
import { normalizePublishStatus } from "./publishStatus"
export type PublishStatusFilter = "all" | PracticePublishStatus
export function textMatchesSearch(
query: string,
...fields: (string | null | undefined)[]
): boolean {
const needle = query.trim().toLowerCase()
if (!needle) return true
return fields.some((field) => (field ?? "").toLowerCase().includes(needle))
}
export function matchesPublishStatusFilter(
publishStatus: string | null | undefined,
filter: PublishStatusFilter,
): boolean {
if (filter === "all") return true
return normalizePublishStatus(publishStatus) === filter
}
export function hasActiveContentFilters(
search: string,
publishStatusFilter: PublishStatusFilter,
): boolean {
return Boolean(search.trim()) || publishStatusFilter !== "all"
}
export function filterBySearchAndPublishStatus<T>(
items: T[],
options: {
search: string
publishStatusFilter: PublishStatusFilter
getSearchFields: (item: T) => (string | null | undefined)[]
getPublishStatus: (item: T) => string | null | undefined
},
): T[] {
const { search, publishStatusFilter, getSearchFields, getPublishStatus } =
options
return items.filter((item) => {
if (
!matchesPublishStatusFilter(getPublishStatus(item), publishStatusFilter)
) {
return false
}
return textMatchesSearch(search, ...getSearchFields(item))
})
}

View File

@ -3,6 +3,7 @@ import type {
ParentContextPractice,
PracticePublishStatus,
} from "../types/course.types"
import { isPublishedPublishStatus, normalizePublishStatus } from "./publishStatus"
export function unwrapPracticesList(
res: {
@ -21,19 +22,11 @@ export function unwrapPracticesList(
export function practicePublishStatus(
practice: ParentContextPractice,
): PracticePublishStatus | null {
const raw = practice.publish_status
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
if (typeof raw === "string") {
const upper = raw.toUpperCase()
if (upper === "DRAFT" || upper === "PUBLISHED") {
return upper as PracticePublishStatus
}
}
return null
return normalizePublishStatus(practice.publish_status)
}
export function isPracticePublished(practice: ParentContextPractice): boolean {
return practicePublishStatus(practice) === "PUBLISHED"
return isPublishedPublishStatus(practice.publish_status)
}
export function isPracticeDraft(practice: ParentContextPractice): boolean {

28
src/lib/publishStatus.ts Normal file
View File

@ -0,0 +1,28 @@
import type { PracticePublishStatus } from "../types/course.types"
export function normalizePublishStatus(
raw?: string | null,
): PracticePublishStatus | null {
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
if (typeof raw === "string") {
const upper = raw.trim().toUpperCase()
if (upper === "DRAFT" || upper === "PUBLISHED") {
return upper as PracticePublishStatus
}
}
return null
}
export function isPublishedPublishStatus(raw?: string | null): boolean {
return normalizePublishStatus(raw) === "PUBLISHED"
}
export function publishStatusLabel(raw?: string | null): string {
return normalizePublishStatus(raw) ?? "DRAFT"
}
export function nextPublishStatus(
current?: string | null,
): PracticePublishStatus {
return isPublishedPublishStatus(current) ? "DRAFT" : "PUBLISHED"
}

View File

@ -29,21 +29,28 @@ import {
getPracticesByParentCourse,
getProgramCourses,
getTopLevelCourseModules,
publishParentLinkedPractice,
updateParentLinkedPractice,
setParentLinkedPracticePublishStatus,
setTopLevelCourseModuleAccessTier,
setTopLevelCourseModulePublishStatus,
updateTopLevelCourseModule,
} from "../../api/courses.api";
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
import type {
ParentContextPractice,
ContentAccessTier,
PracticePublishStatus,
ProgramCourseListItem,
TopLevelCourseModuleItem,
} from "../../types/course.types";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
import { unwrapPracticesList } from "../../lib/parentContextPractice";
import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
@ -166,7 +173,12 @@ export function CourseDetailPage() {
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
const [practiceFilter, setPracticeFilter] = useState("All");
const [moduleSearch, setModuleSearch] = useState("");
const [modulePublishStatusFilter, setModulePublishStatusFilter] =
useState<PublishStatusFilter>("all");
const [practiceSearch, setPracticeSearch] = useState("");
const [practicePublishStatusFilter, setPracticePublishStatusFilter] =
useState<PublishStatusFilter>("all");
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
@ -175,6 +187,12 @@ export function CourseDetailPage() {
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const [publishStatusModuleId, setPublishStatusModuleId] = useState<
number | null
>(null);
const [accessTierModuleId, setAccessTierModuleId] = useState<number | null>(
null,
);
const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module);
@ -309,60 +327,115 @@ export function CourseDetailPage() {
void loadCoursePractices();
}, [activeTab, loadCoursePractices]);
const filteredPractices = useMemo(() => {
if (practiceFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (practiceFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (practiceFilter === "Archived") {
return [];
}
return practices;
}, [practices, practiceFilter]);
const filteredModules = useMemo(
() =>
filterBySearchAndPublishStatus(modules, {
search: moduleSearch,
publishStatusFilter: modulePublishStatusFilter,
getSearchFields: (m) => [m.name, m.description],
getPublishStatus: (m) => m.publish_status,
}),
[modulePublishStatusFilter, moduleSearch, modules],
);
const handlePublishPractice = async (practiceId: number) => {
const filteredPractices = useMemo(
() =>
filterBySearchAndPublishStatus(practices, {
search: practiceSearch,
publishStatusFilter: practicePublishStatusFilter,
getSearchFields: (p) => [
p.title,
p.story_description,
p.quick_tips,
],
getPublishStatus: (p) => p.publish_status,
}),
[practicePublishStatusFilter, practiceSearch, practices],
);
const handlePracticePublishStatus = async (
practiceId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusPracticeId(practiceId);
try {
await publishParentLinkedPractice(practiceId);
await setParentLinkedPracticePublishStatus(practiceId, {
publish_status: nextStatus,
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
),
);
toast.success("Practice published");
toast.success(
nextStatus === "PUBLISHED"
? "Practice published"
: "Practice saved as draft",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice";
?.message ?? "Failed to update practice status";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSavePracticeAsDraft = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
const handleModulePublishStatus = async (
moduleId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusModuleId(moduleId);
try {
await updateParentLinkedPractice(practiceId, {
publish_status: "DRAFT",
await setTopLevelCourseModulePublishStatus(moduleId, {
publish_status: nextStatus,
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
setModules((prev) =>
prev.map((m) =>
m.id === moduleId ? { ...m, publish_status: nextStatus } : m,
),
);
toast.success("Practice saved as draft");
toast.success(
nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft";
?.message ?? "Failed to update module status";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
setPublishStatusModuleId(null);
}
};
const handleModuleAccessTier = async (
moduleId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierModuleId(moduleId);
try {
await setTopLevelCourseModuleAccessTier(moduleId, {
access_tier: nextTier,
});
setModules((prev) =>
prev.map((m) =>
m.id === moduleId ? { ...m, access_tier: nextTier } : m,
),
);
toast.success(
nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update module access tier";
toast.error(msg);
} finally {
setAccessTierModuleId(null);
}
};
@ -471,9 +544,9 @@ export function CourseDetailPage() {
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{displayTitle}
</h1>
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
<ContentPageDescription className="mt-1 text-sm font-medium text-grayScale-500">
{displayDescription}
</p>
</ContentPageDescription>
</div>
<div className="flex items-center gap-4">
<Button
@ -625,13 +698,29 @@ export function CourseDetailPage() {
</p>
</div>
) : (
<div className="space-y-6">
<ContentListSearchFilterBar
search={moduleSearch}
onSearchChange={setModuleSearch}
publishStatusFilter={modulePublishStatusFilter}
onPublishStatusFilterChange={setModulePublishStatusFilter}
searchPlaceholder="Search modules by name or description…"
searchAriaLabel="Search modules"
/>
{filteredModules.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No modules match your search or status filter
</p>
</div>
) : (
<div
className="grid justify-start gap-10"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}}
>
{modules.map((module, index) => {
{filteredModules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? "";
return (
<Card
@ -667,6 +756,30 @@ export function CourseDetailPage() {
<ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1">
<div className="mb-1 flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={module.publish_status}
updating={publishStatusModuleId === module.id}
contentLabel="module"
onToggle={(nextStatus) =>
void handleModulePublishStatus(
module.id,
nextStatus,
)
}
/>
<ContentAccessTierChip
accessTier={module.access_tier}
updating={accessTierModuleId === module.id}
contentLabel="module"
onToggle={(nextTier) =>
void handleModuleAccessTier(
module.id,
nextTier,
)
}
/>
</div>
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name}
</h3>
@ -708,31 +821,19 @@ export function CourseDetailPage() {
);
})}
</div>
)}
</div>
)
) : (
<div className="space-y-8">
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
type="button"
onClick={() => setPracticeFilter(label)}
className={cn(
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
practiceFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
<ContentListSearchFilterBar
search={practiceSearch}
onSearchChange={setPracticeSearch}
publishStatusFilter={practicePublishStatusFilter}
onPublishStatusFilterChange={setPracticePublishStatusFilter}
searchPlaceholder="Search practices by title or description…"
searchAriaLabel="Search practices"
/>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
@ -752,9 +853,14 @@ export function CourseDetailPage() {
onEdit={() =>
navigate(`/content/practices?type=course&id=${courseIdNum}`)
}
onPublish={() => void handlePublishPractice(practice.id)}
onPublish={() =>
void handlePracticePublishStatus(
practice.id,
"PUBLISHED",
)
}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
void handlePracticePublishStatus(practice.id, "DRAFT")
}
/>
))}
@ -769,7 +875,7 @@ export function CourseDetailPage() {
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
{practices.length === 0
? "No practices for this course yet"
: "No practices match this filter"}
: "No practices match your search or status filter"}
</h2>
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
{practices.length === 0

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -28,11 +28,21 @@ import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogUnit,
setExamPrepCatalogUnitAccessTier,
setExamPrepCatalogUnitPublishStatus,
updateExamPrepCatalogUnit,
deleteExamPrepCatalogUnit,
getExamPrepCatalogUnits,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import {
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
export function CourseManagementPage() {
const navigate = useNavigate();
@ -55,12 +65,20 @@ export function CourseManagementPage() {
description: string;
thumbnail: string;
sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
modules: number;
lessons: number;
practices: number;
gradient: string;
}>
>([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
@ -71,6 +89,20 @@ export function CourseManagementPage() {
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
const [deletingUnit, setDeletingUnit] = useState(false);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredUnits = useMemo(
() =>
filterBySearchAndPublishStatus(units, {
search: listSearch,
publishStatusFilter,
getSearchFields: (u) => [u.name, u.description],
getPublishStatus: (u) => u.publishStatus,
}),
[listSearch, publishStatusFilter, units],
);
// Mock data for display titles
const courseTitles: Record<string, string> = {
@ -101,6 +133,8 @@ export function CourseManagementPage() {
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
modules: Number(row.modules_count ?? 0),
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0),
@ -125,6 +159,58 @@ export function CourseManagementPage() {
void loadUnits();
}, [loadUnits]);
const handleUnitPublishStatus = async (
unitId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(unitId);
try {
await setExamPrepCatalogUnitPublishStatus(unitId, {
publish_status: nextStatus,
});
setUnits((prev) =>
prev.map((u) =>
u.id === unitId ? { ...u, publishStatus: nextStatus } : u,
),
);
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 {
setPublishStatusUpdatingId(null);
}
};
const handleUnitAccessTier = async (
unitId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierUpdatingId(unitId);
try {
await setExamPrepCatalogUnitAccessTier(unitId, { access_tier: nextTier });
setUnits((prev) =>
prev.map((u) =>
u.id === unitId ? { ...u, accessTier: nextTier } : u,
),
);
toast.success(
nextTier === "PREMIUM" ? "Unit set to Premium" : "Unit set to Free",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update unit access tier";
toast.error(message);
} finally {
setAccessTierUpdatingId(null);
}
};
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
@ -574,7 +660,18 @@ export function CourseManagementPage() {
</div>
{/* Grid of Units */}
<div className="flex flex-wrap gap-4 pt-4">
<div className="space-y-4 pt-4">
{!unitsLoading && units.length > 0 ? (
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search units by name or description…"
searchAriaLabel="Search units"
/>
) : null}
<div className="flex flex-wrap gap-4">
{unitsLoading ? (
<p className="text-sm text-grayScale-500">Loading units...</p>
) : units.length === 0 ? (
@ -586,8 +683,14 @@ export function CourseManagementPage() {
Create your first unit to start organizing modules, lessons, and practices.
</p>
</div>
) : filteredUnits.length === 0 ? (
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No units match your search or status filter
</p>
</div>
) : (
units.map((unit) => (
filteredUnits.map((unit) => (
<Card
key={unit.id}
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
@ -633,6 +736,24 @@ export function CourseManagementPage() {
<div className="p-4 flex flex-col flex-1 space-y-6">
<div className="space-y-3 flex-1">
<div className="flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={unit.publishStatus}
updating={publishStatusUpdatingId === unit.id}
contentLabel="unit"
onToggle={(nextStatus) =>
void handleUnitPublishStatus(unit.id, nextStatus)
}
/>
<ContentAccessTierChip
accessTier={unit.accessTier}
updating={accessTierUpdatingId === unit.id}
contentLabel="unit"
onToggle={(nextTier) =>
void handleUnitAccessTier(unit.id, nextTier)
}
/>
</div>
<h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
{unit.name}
</h3>
@ -679,6 +800,7 @@ export function CourseManagementPage() {
</Card>
))
)}
</div>
</div>
<Dialog

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
@ -24,10 +24,20 @@ import {
deleteExamPrepModuleLesson,
getExamPrepModuleLessons,
publishExamPrepModuleLesson,
setExamPrepModuleLessonAccessTier,
} from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import type { PracticePublishStatus } from "../../types/course.types";
import type {
ContentAccessTier,
PracticePublishStatus,
} from "../../types/course.types";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import {
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
@ -74,6 +84,7 @@ export function CourseModuleDetailPage() {
thumbnail: string;
sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
durationSeconds: number | null;
}>
>([]);
@ -81,6 +92,23 @@ export function CourseModuleDetailPage() {
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [accessTierLessonId, setAccessTierLessonId] = useState<number | null>(
null,
);
const [lessonSearch, setLessonSearch] = useState("");
const [lessonPublishStatusFilter, setLessonPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredLessons = useMemo(
() =>
filterBySearchAndPublishStatus(lessons, {
search: lessonSearch,
publishStatusFilter: lessonPublishStatusFilter,
getSearchFields: (l) => [l.title, l.description],
getPublishStatus: (l) => l.publishStatus,
}),
[lessonPublishStatusFilter, lessonSearch, lessons],
);
const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState("");
@ -159,6 +187,7 @@ export function CourseModuleDetailPage() {
thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
durationSeconds,
};
}),
@ -505,6 +534,34 @@ export function CourseModuleDetailPage() {
}
};
const handleToggleLessonAccessTier = async (
lessonId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierLessonId(lessonId);
try {
await setExamPrepModuleLessonAccessTier(lessonId, {
access_tier: nextTier,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, accessTier: nextTier } : l,
),
);
toast.success(
nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free",
);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson access tier";
toast.error(message);
} finally {
setAccessTierLessonId(null);
}
};
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
@ -528,9 +585,9 @@ export function CourseModuleDetailPage() {
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
{moduleTitle}
</h1>
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
<ContentPageDescription className="text-[16px] font-medium text-grayScale-400">
{moduleDescription}
</p>
</ContentPageDescription>
</div>
<div className="flex items-center gap-3 pt-2">
@ -780,8 +837,24 @@ export function CourseModuleDetailPage() {
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="space-y-6">
<ContentListSearchFilterBar
search={lessonSearch}
onSearchChange={setLessonSearch}
publishStatusFilter={lessonPublishStatusFilter}
onPublishStatusFilterChange={setLessonPublishStatusFilter}
searchPlaceholder="Search lessons by title or description…"
searchAriaLabel="Search lessons"
/>
{filteredLessons.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons match your search or status filter
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
{filteredLessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
@ -803,9 +876,16 @@ export function CourseModuleDetailPage() {
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
accessTier={lesson.accessTier}
onToggleAccessTier={(nextTier) =>
void handleToggleLessonAccessTier(lesson.id, nextTier)
}
accessTierUpdating={accessTierLessonId === lesson.id}
/>
))}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
@ -21,9 +21,20 @@ import alertSrc from "../../assets/Alert.svg";
import {
getLearningPrograms,
createLearningProgram,
setLearningProgramAccessTier,
setLearningProgramPublishStatus,
updateLearningProgram,
deleteLearningProgram,
} from "../../api/courses.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import {
filterBySearchAndPublishStatus,
hasActiveContentFilters,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
import type { LearningProgramListItem } from "../../types/course.types";
@ -71,6 +82,26 @@ export function LearnEnglishPage() {
const [deletingProgram, setDeletingProgram] =
useState<LearningProgramListItem | null>(null);
const [deleting, setDeleting] = useState(false);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredPrograms = useMemo(
() =>
filterBySearchAndPublishStatus(programs, {
search: listSearch,
publishStatusFilter,
getSearchFields: (p) => [p.name, p.description],
getPublishStatus: (p) => p.publish_status,
}),
[programs, listSearch, publishStatusFilter],
);
const openEdit = (program: LearningProgramListItem) => {
setEditingProgram(program);
@ -254,6 +285,58 @@ export function LearnEnglishPage() {
}
};
const handleProgramPublishStatus = async (
programId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(programId);
try {
await setLearningProgramPublishStatus(programId, {
publish_status: nextStatus,
});
setPrograms((prev) =>
prev.map((p) =>
p.id === programId ? { ...p, publish_status: nextStatus } : p,
),
);
toast.success(
nextStatus === "PUBLISHED" ? "Program published" : "Program saved as draft",
);
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update program status";
toast.error(msg);
} finally {
setPublishStatusUpdatingId(null);
}
};
const handleProgramAccessTier = async (
programId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierUpdatingId(programId);
try {
await setLearningProgramAccessTier(programId, { access_tier: nextTier });
setPrograms((prev) =>
prev.map((p) =>
p.id === programId ? { ...p, access_tier: nextTier } : p,
),
);
toast.success(
nextTier === "PREMIUM" ? "Program set to Premium" : "Program set to Free",
);
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update program access tier";
toast.error(msg);
} finally {
setAccessTierUpdatingId(null);
}
};
const handleConfirmDelete = async () => {
if (!deletingProgram) return;
setDeleting(true);
@ -559,8 +642,29 @@ export function LearnEnglishPage() {
</p>
</div>
) : (
<div className="space-y-6">
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search programs by name or description…"
searchAriaLabel="Search programs"
/>
{filteredPrograms.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No programs match your search or status filter
</p>
{hasActiveContentFilters(listSearch, publishStatusFilter) ? (
<p className="mt-1 text-sm text-grayScale-400">
Try different keywords or clear the publish status filter.
</p>
) : null}
</div>
) : (
<div className="flex flex-wrap gap-10">
{programs.map((program) => (
{filteredPrograms.map((program) => (
<Card
key={program.id}
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
@ -604,6 +708,24 @@ export function LearnEnglishPage() {
/>
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
<div className="flex-1 min-h-0">
<div className="mb-3 flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={program.publish_status}
updating={publishStatusUpdatingId === program.id}
contentLabel="program"
onToggle={(nextStatus) =>
void handleProgramPublishStatus(program.id, nextStatus)
}
/>
<ContentAccessTierChip
accessTier={program.access_tier}
updating={accessTierUpdatingId === program.id}
contentLabel="program"
onToggle={(nextTier) =>
void handleProgramAccessTier(program.id, nextTier)
}
/>
</div>
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
{program.name}
</h3>
@ -627,6 +749,8 @@ export function LearnEnglishPage() {
</Card>
))}
</div>
)}
</div>
)}
<Dialog

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertCircle,
ArrowLeft,
@ -17,6 +17,8 @@ import {
deleteExamPrepPractice,
getExamPrepLessonPractices,
getPracticesByParentLesson,
setExamPrepPracticePublishStatus,
setParentLinkedPracticePublishStatus,
} from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@ -33,7 +35,14 @@ import type {
GetExamPrepLessonPracticesResponse,
GetPracticesByParentContextResponse,
ParentContextPractice,
PracticePublishStatus,
} from "../../types/course.types";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import {
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
@ -83,11 +92,15 @@ function PracticeCard({
index,
total,
onDelete,
onTogglePublishStatus,
publishStatusUpdating,
}: {
practice: ParentContextPractice;
index: number;
total: number;
onDelete?: () => void;
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
@ -141,14 +154,12 @@ function PracticeCard({
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
{practice.publish_status ? (
<Badge
variant={practice.publish_status === "PUBLISHED" ? "default" : "secondary"}
className="text-[10px] font-semibold normal-case"
>
{practice.publish_status}
</Badge>
) : null}
<ContentPublishStatusChip
publishStatus={practice.publish_status}
updating={publishStatusUpdating}
contentLabel="practice"
onToggle={onTogglePublishStatus}
/>
</div>
{onDelete ? (
<Button
@ -231,6 +242,27 @@ export function LessonPracticesPage() {
const [loadError, setLoadError] = useState<string | null>(null);
const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null);
const [deleting, setDeleting] = useState(false);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredPractices = useMemo(
() =>
filterBySearchAndPublishStatus(practices, {
search: listSearch,
publishStatusFilter,
getSearchFields: (p) => [
p.title,
p.story_description,
p.quick_tips,
],
getPublishStatus: (p) => p.publish_status,
}),
[listSearch, practices, publishStatusFilter],
);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
@ -289,6 +321,39 @@ export function LessonPracticesPage() {
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
const handlePracticePublishStatus = async (
practiceId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(practiceId);
try {
if (isExamPrep) {
await setExamPrepPracticePublishStatus(practiceId, {
publish_status: nextStatus,
});
} else {
await setParentLinkedPracticePublishStatus(practiceId, {
publish_status: nextStatus,
});
}
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Practice published"
: "Practice saved as draft",
);
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
toast.error(err.response?.data?.message || "Failed to update practice status");
} finally {
setPublishStatusUpdatingId(null);
}
};
const confirmDeletePractice = async () => {
if (!practiceToDelete) return;
setDeleting(true);
@ -459,17 +524,39 @@ export function LessonPracticesPage() {
</Card>
) : (
<div className="space-y-5">
{practices.map((p, i) => (
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search practices by title or description…"
searchAriaLabel="Search practices"
/>
{filteredPractices.length === 0 ? (
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
<CardContent className="px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No practices match your search or status filter
</p>
</CardContent>
</Card>
) : (
filteredPractices.map((p, i) => (
<PracticeCard
key={p.id}
practice={p}
index={i}
total={practices.length}
total={filteredPractices.length}
onDelete={
isExamPrep ? () => setPracticeToDelete(p) : undefined
}
publishStatusUpdating={publishStatusUpdatingId === p.id}
onTogglePublishStatus={(nextStatus) =>
void handlePracticePublishStatus(p.id, nextStatus)
}
/>
))}
))
)}
</div>
)}
</div>

View File

@ -7,21 +7,18 @@ import {
getModuleLessons,
getPracticesByParentModule,
getTopLevelCourseModules,
publishParentLinkedPractice,
publishTopLevelModuleLesson,
updateParentLinkedPractice,
setParentLinkedPracticePublishStatus,
setTopLevelModuleLessonAccessTier,
updateTopLevelModuleLesson,
} from "../../api/courses.api";
import type {
ContentAccessTier,
ParentContextPractice,
PracticePublishStatus,
TopLevelModuleLessonItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { unwrapPracticesList } from "../../lib/parentContextPractice";
import { Button } from "../../components/ui/button";
import {
Dialog,
@ -38,6 +35,12 @@ import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { VideoCard } from "./components/VideoCard";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import {
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
@ -61,7 +64,12 @@ export function ModuleDetailPage() {
moduleId: string;
}>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("All");
const [lessonSearch, setLessonSearch] = useState("");
const [lessonPublishStatusFilter, setLessonPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const [practiceSearch, setPracticeSearch] = useState("");
const [practicePublishStatusFilter, setPracticePublishStatusFilter] =
useState<PublishStatusFilter>("all");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
@ -82,6 +90,9 @@ export function ModuleDetailPage() {
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [accessTierLessonId, setAccessTierLessonId] = useState<number | null>(
null,
);
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
@ -247,57 +258,56 @@ export function ModuleDetailPage() {
void loadModulePractices();
}, [activeTab, loadModulePractices]);
const filteredPractices = useMemo(() => {
if (activeFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (activeFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (activeFilter === "Archived") {
return [];
}
return practices;
}, [practices, activeFilter]);
const filteredLessons = useMemo(
() =>
filterBySearchAndPublishStatus(lessons, {
search: lessonSearch,
publishStatusFilter: lessonPublishStatusFilter,
getSearchFields: (l) => [l.title, l.description],
getPublishStatus: (l) => l.publish_status,
}),
[lessonPublishStatusFilter, lessonSearch, lessons],
);
const handlePublishPractice = async (practiceId: number) => {
const filteredPractices = useMemo(
() =>
filterBySearchAndPublishStatus(practices, {
search: practiceSearch,
publishStatusFilter: practicePublishStatusFilter,
getSearchFields: (p) => [
p.title,
p.story_description,
p.quick_tips,
],
getPublishStatus: (p) => p.publish_status,
}),
[practicePublishStatusFilter, practiceSearch, practices],
);
const handlePracticePublishStatus = async (
practiceId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusPracticeId(practiceId);
try {
await publishParentLinkedPractice(practiceId);
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
),
);
toast.success("Practice published");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSavePracticeAsDraft = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await updateParentLinkedPractice(practiceId, {
publish_status: "DRAFT",
await setParentLinkedPracticePublishStatus(practiceId, {
publish_status: nextStatus,
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
),
);
toast.success("Practice saved as draft");
toast.success(
nextStatus === "PUBLISHED"
? "Practice published"
: "Practice saved as draft",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft";
?.message ?? "Failed to update practice status";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
@ -391,6 +401,34 @@ export function ModuleDetailPage() {
}
};
const handleToggleLessonAccessTier = async (
lessonId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierLessonId(lessonId);
try {
await setTopLevelModuleLessonAccessTier(lessonId, {
access_tier: nextTier,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, access_tier: nextTier } : l,
),
);
toast.success(
nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update lesson access tier";
toast.error(msg);
} finally {
setAccessTierLessonId(null);
}
};
const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return;
setDeletingLessonInFlight(true);
@ -429,9 +467,9 @@ export function ModuleDetailPage() {
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{displayModuleName}
</h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl">
<ContentPageDescription className="text-[14px] text-grayScale-500">
{displayModuleDescription}
</p>
</ContentPageDescription>
</div>
<div className="flex items-center gap-3">
<Button
@ -502,8 +540,24 @@ export function ModuleDetailPage() {
{lessonsLoadError}
</div>
) : lessons.length > 0 ? (
<div className="space-y-6">
<ContentListSearchFilterBar
search={lessonSearch}
onSearchChange={setLessonSearch}
publishStatusFilter={lessonPublishStatusFilter}
onPublishStatusFilterChange={setLessonPublishStatusFilter}
searchPlaceholder="Search lessons by title or description…"
searchAriaLabel="Search lessons"
/>
{filteredLessons.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No lessons match your search or status filter
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
{filteredLessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
@ -537,9 +591,16 @@ export function ModuleDetailPage() {
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
accessTier={lesson.access_tier}
onToggleAccessTier={(nextTier) =>
void handleToggleLessonAccessTier(lesson.id, nextTier)
}
accessTierUpdating={accessTierLessonId === lesson.id}
/>
))}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
@ -570,28 +631,14 @@ export function ModuleDetailPage() {
)
) : (
<div className="space-y-8">
{/* Practice Tab Filter Bar */}
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
onClick={() => setActiveFilter(label)}
className={cn(
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
activeFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
<ContentListSearchFilterBar
search={practiceSearch}
onSearchChange={setPracticeSearch}
publishStatusFilter={practicePublishStatusFilter}
onPublishStatusFilterChange={setPracticePublishStatusFilter}
searchPlaceholder="Search practices by title or description…"
searchAriaLabel="Search practices"
/>
{practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
@ -613,9 +660,14 @@ export function ModuleDetailPage() {
`/content/practices?type=module&id=${moduleId}`,
)
}
onPublish={() => void handlePublishPractice(practice.id)}
onPublish={() =>
void handlePracticePublishStatus(
practice.id,
"PUBLISHED",
)
}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
void handlePracticePublishStatus(practice.id, "DRAFT")
}
/>
))}
@ -630,12 +682,12 @@ export function ModuleDetailPage() {
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
{practices.length === 0
? "No practices in this module yet"
: "No practices match this filter"}
: "No practices match your search or status filter"}
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
{practices.length === 0
? "Add a practice to give learners speaking exercises for this module."
: "Try another status filter or add a new practice."}
: "Try different keywords or clear the publish status filter."}
</p>
{practices.length === 0 ? (
<Button

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@ -22,11 +22,24 @@ import {
deleteTopLevelCourse,
getLearningPrograms,
getProgramCourses,
setProgramCourseAccessTier,
setProgramCoursePublishStatus,
updateTopLevelCourse,
} from "../../api/courses.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import {
filterBySearchAndPublishStatus,
hasActiveContentFilters,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
import { uploadImageFile } from "../../api/files.api";
import type {
ContentAccessTier,
LearningProgramListItem,
PracticePublishStatus,
ProgramCourseListItem,
} from "../../types/course.types";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
@ -64,9 +77,81 @@ export function ProgramCoursesPage() {
const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredCourses = useMemo(
() =>
filterBySearchAndPublishStatus(courses, {
search: listSearch,
publishStatusFilter,
getSearchFields: (c) => [c.name, c.description],
getPublishStatus: (c) => c.publish_status,
}),
[courses, listSearch, publishStatusFilter],
);
const programIdValid = Number.isFinite(programId) && programId >= 1;
const handleCoursePublishStatus = async (
courseId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(courseId);
try {
await setProgramCoursePublishStatus(courseId, {
publish_status: nextStatus,
});
setCourses((prev) =>
prev.map((c) =>
c.id === courseId ? { ...c, publish_status: nextStatus } : c,
),
);
toast.success(
nextStatus === "PUBLISHED" ? "Course published" : "Course saved as draft",
);
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course status";
toast.error(msg);
} finally {
setPublishStatusUpdatingId(null);
}
};
const handleCourseAccessTier = async (
courseId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierUpdatingId(courseId);
try {
await setProgramCourseAccessTier(courseId, { access_tier: nextTier });
setCourses((prev) =>
prev.map((c) =>
c.id === courseId ? { ...c, access_tier: nextTier } : c,
),
);
toast.success(
nextTier === "PREMIUM" ? "Course set to Premium" : "Course set to Free",
);
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course access tier";
toast.error(msg);
} finally {
setAccessTierUpdatingId(null);
}
};
const loadData = useCallback(async () => {
if (!Number.isFinite(programId) || programId < 1) {
setError("Invalid program");
@ -342,9 +427,9 @@ export function ProgramCoursesPage() {
{programTitle}
</h1>
{programDescription ? (
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
<ContentPageDescription className="text-[15px] text-grayScale-400">
{programDescription}
</p>
</ContentPageDescription>
) : loading ? (
<div className="flex items-center gap-2 pt-1">
<img
@ -579,8 +664,24 @@ export function ProgramCoursesPage() {
</p>
</div>
) : (
<div className="space-y-6">
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search courses by name or description…"
searchAriaLabel="Search courses"
/>
{filteredCourses.length === 0 ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No courses match your search or status filter
</p>
</div>
) : (
<div className="flex flex-wrap gap-10">
{courses.map((course) => {
{filteredCourses.map((course) => {
const modules =
course.module_count ?? course.modules_count ?? 0;
const lessons = course.lesson_count ?? course.videos_count ?? 0;
@ -631,6 +732,24 @@ export function ProgramCoursesPage() {
}
/>
<CardContent className="p-6">
<div className="mb-3 flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={course.publish_status}
updating={publishStatusUpdatingId === course.id}
contentLabel="course"
onToggle={(nextStatus) =>
void handleCoursePublishStatus(course.id, nextStatus)
}
/>
<ContentAccessTierChip
accessTier={course.access_tier}
updating={accessTierUpdatingId === course.id}
contentLabel="course"
onToggle={(nextTier) =>
void handleCourseAccessTier(course.id, nextTier)
}
/>
</div>
<h3 className="text-xl font-bold text-grayScale-700">
{course.name}
</h3>
@ -688,6 +807,8 @@ export function ProgramCoursesPage() {
);
})}
</div>
)}
</div>
)}
<Dialog

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -27,9 +27,21 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepCatalogCourse,
getExamPrepCatalogCourses,
setExamPrepCatalogCourseAccessTier,
setExamPrepCatalogCoursePublishStatus,
updateExamPrepCatalogCourse,
deleteExamPrepCatalogCourse,
} from "../../api/courses.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import { ContentPageDescription } from "./components/ContentPageDescription";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import {
filterBySearchAndPublishStatus,
hasActiveContentFilters,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
import { uploadImageFile } from "../../api/files.api";
import uploadIcon from "../../assets/icons/upload.png";
@ -50,11 +62,19 @@ export function ProgramDetailPage() {
description: string;
thumbnail?: string | null;
sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
unitsCount: number;
modulesCount: number;
lessonsCount: number;
}[]
>([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
@ -99,6 +119,8 @@ export function ProgramDetailPage() {
description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0),
@ -116,21 +138,94 @@ export function ProgramDetailPage() {
useEffect(() => {
void loadCatalogCourses();
}, [loadCatalogCourses]);
const proficiencyCourses = [
...currentProgram.courses,
...createdCourses.map((course) => ({
id: course.id,
name: course.name,
description: course.description,
units_count: course.unitsCount,
modules_count: course.modulesCount,
lessons_count: course.lessonsCount,
logo: null,
thumbnail: course.thumbnail ?? "",
sort_order: course.sortOrder,
buttonText: "View Detail",
})),
];
const proficiencyCourses = useMemo(
() => [
...currentProgram.courses,
...createdCourses.map((course) => ({
id: course.id,
name: course.name,
description: course.description,
units_count: course.unitsCount,
modules_count: course.modulesCount,
lessons_count: course.lessonsCount,
logo: null,
thumbnail: course.thumbnail ?? "",
sort_order: course.sortOrder,
publish_status: course.publishStatus,
access_tier: course.accessTier,
buttonText: "View Detail",
})),
],
[createdCourses, currentProgram.courses],
);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredProficiencyCourses = useMemo(
() =>
filterBySearchAndPublishStatus(proficiencyCourses, {
search: listSearch,
publishStatusFilter,
getSearchFields: (c) => [c.name, c.description],
getPublishStatus: (c) => c.publish_status,
}),
[listSearch, proficiencyCourses, publishStatusFilter],
);
const handleCoursePublishStatus = async (
courseId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(courseId);
try {
await setExamPrepCatalogCoursePublishStatus(courseId, {
publish_status: nextStatus,
});
setCreatedCourses((prev) =>
prev.map((c) =>
c.id === courseId ? { ...c, publishStatus: nextStatus } : c,
),
);
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 {
setPublishStatusUpdatingId(null);
}
};
const handleCourseAccessTier = async (
courseId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierUpdatingId(courseId);
try {
await setExamPrepCatalogCourseAccessTier(courseId, {
access_tier: nextTier,
});
setCreatedCourses((prev) =>
prev.map((c) =>
c.id === courseId ? { ...c, accessTier: nextTier } : c,
),
);
toast.success(
nextTier === "PREMIUM" ? "Course set to Premium" : "Course set to Free",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update course access tier";
toast.error(message);
} finally {
setAccessTierUpdatingId(null);
}
};
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
@ -425,9 +520,9 @@ export function ProgramDetailPage() {
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
{currentProgram.title}
</h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
{currentProgram.description}
</p>
</ContentPageDescription>
</div>
<div className="flex items-center gap-3 pt-2">
@ -582,7 +677,18 @@ export function ProgramDetailPage() {
</div>
{/* Cards Grid */}
<div className="flex flex-wrap gap-8 mt-10">
<div className="mt-10 space-y-6">
{programType === "proficiency" && !catalogLoading && proficiencyCourses.length > 0 ? (
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search courses by name or description…"
searchAriaLabel="Search catalog courses"
/>
) : null}
<div className="flex flex-wrap gap-8">
{programType === "proficiency" && catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
) : null}
@ -598,9 +704,20 @@ export function ProgramDetailPage() {
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
</p>
</div>
) : programType === "proficiency" && filteredProficiencyCourses.length === 0 ? (
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No courses match your search or status filter
</p>
{hasActiveContentFilters(listSearch, publishStatusFilter) ? (
<p className="mt-1 text-sm text-grayScale-400">
Try different keywords or clear the publish status filter.
</p>
) : null}
</div>
) : (
(programType === "proficiency"
? proficiencyCourses
? filteredProficiencyCourses
: currentProgram.courses
).map((course: any) => (
<Card
@ -650,6 +767,26 @@ export function ProgramDetailPage() {
{/* Content */}
<div className="space-y-4 pt-2 flex-1">
{programType === "proficiency" ? (
<div className="flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={course.publish_status}
updating={publishStatusUpdatingId === Number(course.id)}
contentLabel="course"
onToggle={(nextStatus) =>
void handleCoursePublishStatus(Number(course.id), nextStatus)
}
/>
<ContentAccessTierChip
accessTier={course.access_tier}
updating={accessTierUpdatingId === Number(course.id)}
contentLabel="course"
onToggle={(nextTier) =>
void handleCourseAccessTier(Number(course.id), nextTier)
}
/>
</div>
) : null}
<h3 className="text-[18px] font-medium text-grayScale-900">
{course.name}
</h3>
@ -693,6 +830,7 @@ export function ProgramDetailPage() {
</Card>
))
)}
</div>
</div>
<Dialog

View File

@ -20,7 +20,8 @@ export function ReorderContentPage() {
</h1>
<p className="max-w-2xl text-sm text-grayScale-500">
Drag and drop programs, courses, modules, and lessons to change
their display order.
their display order. Changes are saved automatically when you drop an
item.
</p>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
ArrowLeft,
@ -28,10 +28,20 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
import {
createExamPrepUnitModule,
getExamPrepUnitModules,
setExamPrepUnitModuleAccessTier,
setExamPrepUnitModulePublishStatus,
updateExamPrepUnitModule,
deleteExamPrepUnitModule,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
import {
filterBySearchAndPublishStatus,
type PublishStatusFilter,
} from "../../lib/contentListFilters";
export function UnitManagementPage() {
const navigate = useNavigate();
@ -70,11 +80,19 @@ export function UnitManagementPage() {
thumbnail: string;
icon: string;
sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
lessons: number;
practices: number;
gradient: string;
}>
>([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editThumbnail, setEditThumbnail] = useState("");
@ -87,6 +105,20 @@ export function UnitManagementPage() {
const editIconFileInputRef = useRef<HTMLInputElement>(null);
const [deletingModuleId, setDeletingModuleId] = useState<number | null>(null);
const [deletingModule, setDeletingModule] = useState(false);
const [listSearch, setListSearch] = useState("");
const [publishStatusFilter, setPublishStatusFilter] =
useState<PublishStatusFilter>("all");
const filteredModules = useMemo(
() =>
filterBySearchAndPublishStatus(modules, {
search: listSearch,
publishStatusFilter,
getSearchFields: (m) => [m.name, m.description],
getPublishStatus: (m) => m.publishStatus,
}),
[listSearch, modules, publishStatusFilter],
);
const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://");
@ -131,6 +163,8 @@ export function UnitManagementPage() {
thumbnail: row.thumbnail?.trim() || "",
icon: row.icon?.trim() || "",
sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0),
gradient:
@ -154,6 +188,58 @@ export function UnitManagementPage() {
void loadModules();
}, [loadModules]);
const handleModulePublishStatus = async (
moduleId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusUpdatingId(moduleId);
try {
await setExamPrepUnitModulePublishStatus(moduleId, {
publish_status: nextStatus,
});
setModules((prev) =>
prev.map((m) =>
m.id === moduleId ? { ...m, publishStatus: nextStatus } : m,
),
);
toast.success(
nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update module status";
toast.error(message);
} finally {
setPublishStatusUpdatingId(null);
}
};
const handleModuleAccessTier = async (
moduleId: number,
nextTier: ContentAccessTier,
) => {
setAccessTierUpdatingId(moduleId);
try {
await setExamPrepUnitModuleAccessTier(moduleId, { access_tier: nextTier });
setModules((prev) =>
prev.map((m) =>
m.id === moduleId ? { ...m, accessTier: nextTier } : m,
),
);
toast.success(
nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free",
);
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update module access tier";
toast.error(message);
} finally {
setAccessTierUpdatingId(null);
}
};
const clearCreateModuleForm = () => {
setCreateName("");
setCreateThumbnail("");
@ -655,7 +741,18 @@ export function UnitManagementPage() {
</div>
{/* Grid of Modules */}
<div className="flex flex-wrap gap-4 pt-4">
<div className="space-y-4 pt-4">
{!modulesLoading && modules.length > 0 ? (
<ContentListSearchFilterBar
search={listSearch}
onSearchChange={setListSearch}
publishStatusFilter={publishStatusFilter}
onPublishStatusFilterChange={setPublishStatusFilter}
searchPlaceholder="Search modules by name or description…"
searchAriaLabel="Search modules"
/>
) : null}
<div className="flex flex-wrap gap-4">
{modulesLoading ? (
<p className="text-sm text-grayScale-500">Loading modules...</p>
) : modules.length === 0 ? (
@ -667,8 +764,14 @@ export function UnitManagementPage() {
Create your first module to start organizing lessons and practices.
</p>
</div>
) : filteredModules.length === 0 ? (
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
<p className="text-sm font-medium text-grayScale-600">
No modules match your search or status filter
</p>
</div>
) : (
modules.map((module, index) => (
filteredModules.map((module, index) => (
<Card
key={`${module.id}-${index}`}
className="group relative flex w-[400px] flex-col bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
@ -730,6 +833,24 @@ export function UnitManagementPage() {
</div>
<div className="space-y-1">
<div className="mb-1 flex flex-wrap gap-2">
<ContentPublishStatusChip
publishStatus={module.publishStatus}
updating={publishStatusUpdatingId === module.id}
contentLabel="module"
onToggle={(nextStatus) =>
void handleModulePublishStatus(module.id, nextStatus)
}
/>
<ContentAccessTierChip
accessTier={module.accessTier}
updating={accessTierUpdatingId === module.id}
contentLabel="module"
onToggle={(nextTier) =>
void handleModuleAccessTier(module.id, nextTier)
}
/>
</div>
<h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
{module.name}
</h3>
@ -771,6 +892,7 @@ export function UnitManagementPage() {
</Card>
))
)}
</div>
</div>
<Dialog

View File

@ -0,0 +1,96 @@
import type { ContentAccessTier } from "../../../types/course.types"
import { Button } from "../../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog"
export function accessTierConfirmTitle(
nextTier: ContentAccessTier,
contentLabel = "content",
): string {
if (nextTier === "PREMIUM") {
return `Set this ${contentLabel} to Premium?`
}
return `Set this ${contentLabel} to Free?`
}
export function accessTierConfirmDescription(
nextTier: ContentAccessTier,
contentLabel = "content",
): string {
if (nextTier === "PREMIUM") {
return `Learners will need a premium subscription to access this ${contentLabel}.`
}
return `This ${contentLabel} will be available to all learners at no extra cost.`
}
type AccessTierConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
nextTier: ContentAccessTier | null
contentLabel?: string
onConfirm: () => void
confirming?: boolean
}
export function AccessTierConfirmDialog({
open,
onOpenChange,
nextTier,
contentLabel = "content",
onConfirm,
confirming = false,
}: AccessTierConfirmDialogProps) {
if (!nextTier) return null
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!confirming) onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{accessTierConfirmTitle(nextTier, contentLabel)}
</DialogTitle>
<DialogDescription>
{accessTierConfirmDescription(nextTier, contentLabel)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
disabled={confirming}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={confirming}
className={
nextTier === "PREMIUM"
? "bg-amber-600 hover:bg-amber-700"
: "bg-sky-600 hover:bg-sky-700"
}
onClick={onConfirm}
>
{confirming
? "Updating…"
: nextTier === "PREMIUM"
? "Set Premium"
: "Set Free"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,104 @@
import { useState } from "react"
import { Crown, Loader2, Sparkles } from "lucide-react"
import type { ContentAccessTier } from "../../../types/course.types"
import {
accessTierLabel,
isPremiumAccessTier,
nextAccessTier,
} from "../../../lib/accessTier"
import { cn } from "../../../lib/utils"
import { AccessTierConfirmDialog } from "./AccessTierConfirmDialog"
type ContentAccessTierChipProps = {
accessTier?: string | null
updating?: boolean
onToggle?: (nextTier: ContentAccessTier) => void
contentLabel?: string
className?: string
}
export function ContentAccessTierChip({
accessTier,
updating = false,
onToggle,
contentLabel = "content",
className,
}: ContentAccessTierChipProps) {
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingTier, setPendingTier] = useState<ContentAccessTier | null>(null)
const label = accessTierLabel(accessTier)
const isPremium = isPremiumAccessTier(accessTier)
const interactive = Boolean(onToggle) && !updating
const body = (
<>
{updating ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : isPremium ? (
<Crown className="h-3 w-3 shrink-0" aria-hidden />
) : (
<Sparkles className="h-3 w-3 shrink-0" aria-hidden />
)}
<span>{label}</span>
</>
)
const chipClass = cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider",
isPremium
? "border-amber-200 bg-gradient-to-r from-amber-50 to-amber-100/80 text-amber-800 shadow-sm shadow-amber-100"
: "border-sky-200 bg-gradient-to-r from-sky-50 to-sky-100/60 text-sky-700",
interactive && "cursor-pointer transition-all hover:brightness-[0.98]",
updating && "opacity-70",
className,
)
const requestToggle = (e?: React.MouseEvent) => {
e?.stopPropagation()
if (!onToggle || updating) return
const next = nextAccessTier(accessTier)
setPendingTier(next)
setConfirmOpen(true)
}
const handleConfirm = () => {
if (!onToggle || !pendingTier) return
onToggle(pendingTier)
setConfirmOpen(false)
setPendingTier(null)
}
if (!onToggle) {
return <span className={chipClass}>{body}</span>
}
return (
<>
<button
type="button"
className={chipClass}
disabled={updating}
title={
isPremium
? "Click to set as Free"
: "Click to set as Premium"
}
onClick={requestToggle}
>
{body}
</button>
<AccessTierConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
setConfirmOpen(open)
if (!open) setPendingTier(null)
}}
nextTier={pendingTier}
contentLabel={contentLabel}
confirming={updating}
onConfirm={handleConfirm}
/>
</>
)
}

View File

@ -36,13 +36,24 @@ import {
Loader2,
} from "lucide-react";
import { cn } from "../../../lib/utils";
import { toast } from "sonner";
import {
getLearningPrograms,
getProgramCourses,
getTopLevelCourseModules,
getModuleLessons,
reorderLearningPrograms,
reorderProgramCourses,
reorderTopLevelCourseModules,
reorderModuleLessons,
} from "../../../api/courses.api";
function sortBySortOrder<T extends { sort_order?: number }>(items: T[]): T[] {
return [...items].sort(
(a, b) => Number(a.sort_order ?? 0) - Number(b.sort_order ?? 0),
);
}
// --- Types ---
export type ItemType = "program" | "course" | "module" | "lesson";
@ -327,7 +338,9 @@ export function ContentHierarchyList() {
// 1. Fetch Programs
const programsRes = await getLearningPrograms();
const programData = programsRes.data?.data;
const fetchedPrograms: Program[] = (programData?.programs || []).map(
const fetchedPrograms: Program[] = sortBySortOrder(
programData?.programs || [],
).map(
(p) => ({
id: String(p.id),
name: p.name,
@ -347,7 +360,7 @@ export function ContentHierarchyList() {
const coursesResults = await Promise.all(coursesPromises);
const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => {
const courseData = res.data?.data;
return (courseData?.courses || []).map((c) => ({
return sortBySortOrder(courseData?.courses || []).map((c) => ({
id: String(c.id),
name: c.name,
thumbnail: c.thumbnail_url || c.thumbnail || undefined,
@ -367,7 +380,7 @@ export function ContentHierarchyList() {
const modulesResults = await Promise.all(modulesPromises);
const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => {
const moduleData = res.data?.data;
return (moduleData?.modules || []).map((m) => ({
return sortBySortOrder(moduleData?.modules || []).map((m) => ({
id: String(m.id),
name: m.name,
thumbnail: m.icon || undefined,
@ -387,7 +400,7 @@ export function ContentHierarchyList() {
const lessonsResults = await Promise.all(lessonsPromises);
const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => {
const lessonData = res.data?.data;
return (lessonData?.lessons || []).map((l) => ({
return sortBySortOrder(lessonData?.lessons || []).map((l) => ({
id: String(l.id),
name: l.title,
thumbnail: l.thumbnail || undefined,
@ -410,16 +423,115 @@ export function ContentHierarchyList() {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
};
const reorder = <T extends BaseItem>(
list: T[],
setList: React.Dispatch<React.SetStateAction<T[]>>,
activeId: UniqueIdentifier,
overId: UniqueIdentifier,
const toOrderedIds = (items: BaseItem[]) =>
items.map((item) => Number(item.id));
const reorderSiblings = <T extends BaseItem>(
siblings: T[],
activeId: string,
overId: string,
): T[] | null => {
const oldIndex = siblings.findIndex((i) => i.id === activeId);
const newIndex = siblings.findIndex((i) => i.id === overId);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return null;
}
return arrayMove(siblings, oldIndex, newIndex);
};
const reorderErrorMessage = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string } } })
?.response?.data?.message;
return typeof message === "string" && message.trim() ? message : fallback;
};
const handleProgramReorder = async (activeId: string, overId: string) => {
const reordered = reorderSiblings(programs, activeId, overId);
if (!reordered) return;
const previous = programs;
setPrograms(reordered);
try {
await reorderLearningPrograms({ ordered_ids: toOrderedIds(reordered) });
toast.success("Programs reordered");
} catch (error) {
setPrograms(previous);
toast.error(reorderErrorMessage(error, "Failed to reorder programs"));
}
};
const handleCourseReorder = async (
programId: string,
activeId: string,
overId: string,
) => {
const oldIndex = list.findIndex((i) => i.id === String(activeId));
const newIndex = list.findIndex((i) => i.id === String(overId));
if (oldIndex !== -1 && newIndex !== -1) {
setList(arrayMove(list, oldIndex, newIndex));
const siblings = courses.filter((course) => course.programId === programId);
const reordered = reorderSiblings(siblings, activeId, overId);
if (!reordered) return;
const previous = courses;
setCourses((prev) => [
...prev.filter((course) => course.programId !== programId),
...reordered,
]);
try {
await reorderProgramCourses(Number(programId), {
ordered_ids: toOrderedIds(reordered),
});
toast.success("Courses reordered");
} catch (error) {
setCourses(previous);
toast.error(reorderErrorMessage(error, "Failed to reorder courses"));
}
};
const handleModuleReorder = async (
courseId: string,
activeId: string,
overId: string,
) => {
const siblings = modules.filter((module) => module.courseId === courseId);
const reordered = reorderSiblings(siblings, activeId, overId);
if (!reordered) return;
const previous = modules;
setModules((prev) => [
...prev.filter((module) => module.courseId !== courseId),
...reordered,
]);
try {
await reorderTopLevelCourseModules(Number(courseId), {
ordered_ids: toOrderedIds(reordered),
});
toast.success("Modules reordered");
} catch (error) {
setModules(previous);
toast.error(reorderErrorMessage(error, "Failed to reorder modules"));
}
};
const handleLessonReorder = async (
moduleId: string,
activeId: string,
overId: string,
) => {
const siblings = lessons.filter((lesson) => lesson.moduleId === moduleId);
const reordered = reorderSiblings(siblings, activeId, overId);
if (!reordered) return;
const previous = lessons;
setLessons((prev) => [
...prev.filter((lesson) => lesson.moduleId !== moduleId),
...reordered,
]);
try {
await reorderModuleLessons(Number(moduleId), {
ordered_ids: toOrderedIds(reordered),
});
toast.success("Lessons reordered");
} catch (error) {
setLessons(previous);
toast.error(reorderErrorMessage(error, "Failed to reorder lessons"));
}
};
@ -487,7 +599,7 @@ export function ContentHierarchyList() {
<DraggableList
items={programs}
onReorder={(active, over) =>
reorder(programs, setPrograms, active, over)
void handleProgramReorder(active, over)
}
icon={<LayoutGrid className="h-4 w-4" />}
onEdit={(id) => handleEdit("program", id)}
@ -521,7 +633,7 @@ export function ContentHierarchyList() {
<DraggableList
items={programCourses}
onReorder={(active, over) =>
reorder(courses, setCourses, active, over)
void handleCourseReorder(program.id, active, over)
}
icon={<BookOpen className="h-4 w-4" />}
onEdit={(id) => handleEdit("course", id)}
@ -558,7 +670,7 @@ export function ContentHierarchyList() {
<DraggableList
items={courseModules}
onReorder={(active, over) =>
reorder(modules, setModules, active, over)
void handleModuleReorder(course.id, active, over)
}
icon={<Layers className="h-4 w-4" />}
onEdit={(id) => handleEdit("module", id)}
@ -595,7 +707,7 @@ export function ContentHierarchyList() {
<DraggableList
items={moduleLessons}
onReorder={(active, over) =>
reorder(lessons, setLessons, active, over)
void handleLessonReorder(module.id, active, over)
}
icon={<PlayCircle className="h-4 w-4" />}
onEdit={(id) => handleEdit("lesson", id)}

View File

@ -0,0 +1,60 @@
import { Search } from "lucide-react"
import { Input } from "../../../components/ui/input"
import { Select } from "../../../components/ui/select"
import type { PublishStatusFilter } from "../../../lib/contentListFilters"
import { cn } from "../../../lib/utils"
type ContentListSearchFilterBarProps = {
search: string
onSearchChange: (value: string) => void
publishStatusFilter: PublishStatusFilter
onPublishStatusFilterChange: (value: PublishStatusFilter) => void
searchPlaceholder?: string
searchAriaLabel?: string
className?: string
}
export function ContentListSearchFilterBar({
search,
onSearchChange,
publishStatusFilter,
onPublishStatusFilterChange,
searchPlaceholder = "Search by name or description…",
searchAriaLabel = "Search content",
className,
}: ContentListSearchFilterBarProps) {
return (
<div
className={cn(
"flex flex-wrap items-center gap-3 rounded-xl border border-grayScale-100 bg-white p-4 shadow-sm",
className,
)}
>
<div className="relative min-w-[200px] flex-1">
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400"
aria-hidden
/>
<Input
className="pl-9"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
aria-label={searchAriaLabel}
/>
</div>
<Select
className="w-full sm:w-48 sm:shrink-0"
value={publishStatusFilter}
onChange={(e) =>
onPublishStatusFilterChange(e.target.value as PublishStatusFilter)
}
aria-label="Filter by publish status"
>
<option value="all">All statuses</option>
<option value="PUBLISHED">Published</option>
<option value="DRAFT">Draft</option>
</Select>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { useEffect, useRef, useState, type ReactNode } from "react"
import { cn } from "../../../lib/utils"
const PLACEHOLDER_VALUES = new Set(["—", "-", "Loading…", "Loading..."])
type ContentPageDescriptionProps = {
children: ReactNode
className?: string
collapsedLines?: 2 | 3
}
function getTextContent(children: ReactNode): string {
if (typeof children === "string") return children.trim()
if (typeof children === "number") return String(children).trim()
return ""
}
export function ContentPageDescription({
children,
className,
collapsedLines = 2,
}: ContentPageDescriptionProps) {
const text = getTextContent(children)
const isPlaceholder = !text || PLACEHOLDER_VALUES.has(text)
const [expanded, setExpanded] = useState(false)
const [isTruncated, setIsTruncated] = useState(false)
const ref = useRef<HTMLParagraphElement>(null)
useEffect(() => {
setExpanded(false)
}, [text])
useEffect(() => {
if (isPlaceholder || expanded) return
const el = ref.current
if (!el) return
const checkTruncation = () => {
setIsTruncated(el.scrollHeight > el.clientHeight + 1)
}
checkTruncation()
const observer = new ResizeObserver(checkTruncation)
observer.observe(el)
return () => observer.disconnect()
}, [text, expanded, isPlaceholder, collapsedLines])
if (!isPlaceholder && typeof children !== "string" && typeof children !== "number") {
return <div className={cn("max-w-2xl", className)}>{children}</div>
}
const lineClampClass = collapsedLines === 3 ? "line-clamp-3" : "line-clamp-2"
return (
<div className="max-w-2xl">
<p
ref={ref}
className={cn(
"leading-relaxed",
!expanded && !isPlaceholder && lineClampClass,
className,
)}
>
{children}
</p>
{!isPlaceholder && (isTruncated || expanded) ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="mt-1 text-sm font-semibold text-brand-500 transition-colors hover:text-brand-600"
>
{expanded ? "Show less" : "Show more"}
</button>
) : null}
</div>
)
}

View File

@ -0,0 +1,107 @@
import { useState } from "react"
import { Loader2 } from "lucide-react"
import type { PracticePublishStatus } from "../../../types/course.types"
import {
isPublishedPublishStatus,
nextPublishStatus,
publishStatusLabel,
} from "../../../lib/publishStatus"
import { cn } from "../../../lib/utils"
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog"
type ContentPublishStatusChipProps = {
publishStatus?: string | null
updating?: boolean
onToggle?: (nextStatus: PracticePublishStatus) => void
contentLabel?: string
className?: string
}
export function ContentPublishStatusChip({
publishStatus,
updating = false,
onToggle,
contentLabel = "content",
className,
}: ContentPublishStatusChipProps) {
const [confirmOpen, setConfirmOpen] = useState(false)
const [pendingStatus, setPendingStatus] = useState<PracticePublishStatus | null>(
null,
)
const label = publishStatusLabel(publishStatus)
const isPublished = isPublishedPublishStatus(publishStatus)
const interactive = Boolean(onToggle) && !updating
const body = (
<>
{updating ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
/>
)}
<span>{label}</span>
</>
)
const chipClass = cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-500",
interactive && "cursor-pointer transition-colors hover:opacity-90",
updating && "opacity-70",
className,
)
const requestToggle = (e?: React.MouseEvent) => {
e?.stopPropagation()
if (!onToggle || updating) return
const next = nextPublishStatus(publishStatus)
setPendingStatus(next)
setConfirmOpen(true)
}
const handleConfirm = () => {
if (!onToggle || !pendingStatus) return
onToggle(pendingStatus)
setConfirmOpen(false)
setPendingStatus(null)
}
if (!onToggle) {
return <span className={chipClass}>{body}</span>
}
return (
<>
<button
type="button"
className={chipClass}
disabled={updating}
title={
isPublished ? "Click to save as draft" : "Click to publish"
}
onClick={requestToggle}
>
{body}
</button>
<PublishStatusConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
setConfirmOpen(open)
if (!open) setPendingStatus(null)
}}
nextStatus={pendingStatus}
contentLabel={contentLabel}
confirming={updating}
onConfirm={handleConfirm}
/>
</>
)
}

View File

@ -9,13 +9,17 @@ import {
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { ResolvedImage } from "../../../components/media/ResolvedImage";
import type { ParentContextPractice } from "../../../types/course.types";
import type {
ParentContextPractice,
PracticePublishStatus,
} from "../../../types/course.types";
import {
isPracticePublished,
practicePublishStatus,
} from "../../../lib/parentContextPractice";
import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
import { cn } from "../../../lib/utils";
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
type ModulePracticeCardProps = {
practice: ParentContextPractice;
@ -39,117 +43,152 @@ export function ModulePracticeCard({
[practice.story_image],
);
const [thumbFailed, setThumbFailed] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStatus, setPendingStatus] =
useState<PracticePublishStatus | null>(null);
useEffect(() => {
setThumbFailed(false);
}, [thumbnailSrc]);
return (
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
{thumbnailSrc && !thumbFailed ? (
<ResolvedImage
src={thumbnailSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
</div>
const requestStatusChange = (
nextStatus: PracticePublishStatus,
e?: React.MouseEvent,
) => {
e?.stopPropagation();
if (statusUpdating) return;
setPendingStatus(nextStatus);
setConfirmOpen(true);
};
<div className="flex flex-1 flex-col space-y-5 p-5">
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
)}
>
const confirmStatusChange = () => {
if (!pendingStatus) return;
if (pendingStatus === "PUBLISHED") {
onPublish?.();
} else {
onSaveAsDraft?.();
}
setConfirmOpen(false);
setPendingStatus(null);
};
return (
<>
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
{thumbnailSrc && !thumbFailed ? (
<ResolvedImage
src={thumbnailSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
onError={() => setThumbFailed(true)}
/>
) : null}
</div>
<div className="flex flex-1 flex-col space-y-5 p-5">
<div className="flex items-center justify-between gap-2">
<div
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
)}
/>
{statusLabel}
</div>
<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={statusUpdating}
aria-label={`Practice options: ${practice.title}`}
onClick={(e) => e.stopPropagation()}
>
{statusUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
>
<div
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={statusUpdating}
onClick={(e) => {
e.stopPropagation();
if (isPublished) {
onSaveAsDraft?.();
} else {
onPublish?.();
}
}}
>
{isPublished ? "Save as draft" : "Publish practice"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
/>
{statusLabel}
</div>
<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={statusUpdating}
aria-label={`Practice options: ${practice.title}`}
onClick={(e) => e.stopPropagation()}
>
{statusUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={statusUpdating}
onClick={(e) => {
requestStatusChange(
isPublished ? "DRAFT" : "PUBLISHED",
e,
);
}}
>
{isPublished ? "Save as draft" : "Publish practice"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
{practice.title}
</h3>
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
{practice.title}
</h3>
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
type="button"
disabled={isPublished || statusUpdating}
className={cn(
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
isPublished
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
onClick={(e) => {
e.stopPropagation();
if (!isPublished) onPublish?.();
}}
>
{statusUpdating
? "Updating…"
: isPublished
? "Published"
: "Publish"}
</Button>
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
<Button
type="button"
variant="outline"
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
type="button"
disabled={isPublished || statusUpdating}
className={cn(
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
isPublished
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
: "bg-brand-500 text-white hover:bg-brand-600",
)}
onClick={(e) => {
e.stopPropagation();
if (!isPublished) requestStatusChange("PUBLISHED", e);
}}
>
{statusUpdating
? "Updating…"
: isPublished
? "Published"
: "Publish"}
</Button>
</div>
</div>
</div>
</Card>
</Card>
<PublishStatusConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
setConfirmOpen(open);
if (!open) setPendingStatus(null);
}}
nextStatus={pendingStatus}
contentLabel="practice"
confirming={statusUpdating}
onConfirm={confirmStatusChange}
/>
</>
);
}

View File

@ -0,0 +1,96 @@
import type { PracticePublishStatus } from "../../../types/course.types"
import { Button } from "../../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog"
export function publishStatusConfirmTitle(
nextStatus: PracticePublishStatus,
contentLabel = "content",
): string {
if (nextStatus === "PUBLISHED") {
return `Publish this ${contentLabel}?`
}
return `Save this ${contentLabel} as draft?`
}
export function publishStatusConfirmDescription(
nextStatus: PracticePublishStatus,
contentLabel = "content",
): string {
if (nextStatus === "PUBLISHED") {
return `This ${contentLabel} will be visible to learners once published.`
}
return `This ${contentLabel} will be hidden from learners while saved as a draft.`
}
type PublishStatusConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
nextStatus: PracticePublishStatus | null
contentLabel?: string
onConfirm: () => void
confirming?: boolean
}
export function PublishStatusConfirmDialog({
open,
onOpenChange,
nextStatus,
contentLabel = "content",
onConfirm,
confirming = false,
}: PublishStatusConfirmDialogProps) {
if (!nextStatus) return null
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!confirming) onOpenChange(nextOpen)
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{publishStatusConfirmTitle(nextStatus, contentLabel)}
</DialogTitle>
<DialogDescription>
{publishStatusConfirmDescription(nextStatus, contentLabel)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
disabled={confirming}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={confirming}
className={
nextStatus === "PUBLISHED"
? "bg-brand-500 hover:bg-brand-600"
: undefined
}
onClick={onConfirm}
>
{confirming
? "Updating…"
: nextStatus === "PUBLISHED"
? "Publish"
: "Save as draft"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -33,7 +33,12 @@ import {
isDirectVideoFileUrl,
} from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
import type { PracticePublishStatus } from "../../../types/course.types";
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
import type {
ContentAccessTier,
PracticePublishStatus,
} from "../../../types/course.types";
import { ContentAccessTierChip } from "./ContentAccessTierChip";
function resolvePublishBadge(
publishStatus?: PracticePublishStatus | string | null,
@ -90,6 +95,9 @@ interface VideoCardProps {
/** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean;
accessTier?: ContentAccessTier | string | null;
onToggleAccessTier?: (nextTier: ContentAccessTier) => void;
accessTierUpdating?: boolean;
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null;
}
@ -110,6 +118,9 @@ export function VideoCard({
onViewPractices,
onTogglePublishStatus,
publishStatusUpdating = false,
accessTier,
onToggleAccessTier,
accessTierUpdating = false,
hoverModuleActions = false,
description,
}: VideoCardProps) {
@ -118,6 +129,9 @@ export function VideoCard({
number | null
>(null);
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. */
const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0);
@ -138,6 +152,23 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength(
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(
publishStatus,
status,
@ -242,6 +273,7 @@ export function VideoCard({
};
return (
<>
<div
className={cn(
"group relative flex h-full min-h-0 flex-col overflow-hidden rounded-[24px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg",
@ -453,30 +485,39 @@ export function VideoCard({
"justify-between",
)}
>
{/* Publish status badge */}
{publishBadge ? (
<div
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",
publishBadge.isPublished
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
)}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{publishBadge ? (
<div
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
"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
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
)}
>
<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="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
Lesson
</div>
)}
{accessTier != null || onToggleAccessTier ? (
<ContentAccessTierChip
accessTier={accessTier}
updating={accessTierUpdating}
contentLabel="lesson"
onToggle={onToggleAccessTier}
/>
{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="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
Lesson
</div>
)}
) : null}
</div>
{hoverModuleActions && onTogglePublishStatus ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -485,11 +526,11 @@ export function VideoCard({
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}
disabled={publishStatusUpdating || accessTierUpdating}
aria-label={`Lesson options: ${title}`}
onClick={(e) => e.stopPropagation()}
>
{publishStatusUpdating ? (
{publishStatusUpdating || accessTierUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<MoreVertical className="h-5 w-5" />
@ -498,11 +539,11 @@ export function VideoCard({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={publishStatusUpdating}
disabled={publishStatusUpdating || accessTierUpdating}
onClick={(e) => {
e.stopPropagation();
onTogglePublishStatus(
requestPublishStatusChange(
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
e,
);
}}
>
@ -578,5 +619,17 @@ export function VideoCard({
) : null}
</div>
</div>
<PublishStatusConfirmDialog
open={publishConfirmOpen}
onOpenChange={(open) => {
setPublishConfirmOpen(open);
if (!open) setPendingPublishStatus(null);
}}
nextStatus={pendingPublishStatus}
contentLabel="lesson"
confirming={publishStatusUpdating}
onConfirm={confirmPublishStatusChange}
/>
</>
);
}

View File

@ -24,6 +24,7 @@ import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner";
import { InviteTeamMemberDialog } from "../role-management/components/InviteTeamMemberDialog";
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
@ -96,9 +97,9 @@ export function TeamManagementPage() {
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [updating, setUpdating] = useState(false);
const [loading, setLoading] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
useEffect(() => {
const fetchMembers = async () => {
const fetchMembers = async () => {
setLoading(true);
try {
const batchSize = 100;
@ -128,7 +129,8 @@ export function TeamManagementPage() {
}
};
fetchMembers();
useEffect(() => {
void fetchMembers();
}, []);
const filteredMembers = useMemo(() => {
@ -224,7 +226,7 @@ export function TeamManagementPage() {
</div>
<Button
className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto"
onClick={() => navigate("/team/add")}
onClick={() => setInviteOpen(true)}
>
<Plus className="h-4 w-4" />
Add Team Member
@ -473,6 +475,12 @@ export function TeamManagementPage() {
</div>
{/* Status Update Confirmation Modal */}
<InviteTeamMemberDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void fetchMembers()}
/>
{confirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">

View File

@ -66,6 +66,8 @@ export interface LearningProgramListItem {
description?: string | null
thumbnail?: string | null
sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string
}
@ -111,6 +113,8 @@ export interface ProgramCourseListItem {
name: string
description: string
sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string
thumbnail?: string | null
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
@ -159,6 +163,8 @@ export interface ExamPrepCatalogCourseItem {
units_count?: number
modules_count?: number
lessons_count?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at?: string
updated_at?: string
}
@ -212,6 +218,8 @@ export interface ExamPrepCatalogUnitItem {
description?: string | null
thumbnail?: string | null
sort_order?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
modules_count?: number
lessons_count?: number
videos_count?: number
@ -271,6 +279,8 @@ export interface ExamPrepUnitModuleItem {
thumbnail?: string | null
icon?: string | null
sort_order?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
lessons_count?: number
videos_count?: number
practices_count?: number
@ -331,6 +341,7 @@ export interface ExamPrepModuleLessonItem {
description?: string | null
sort_order?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
/** Total length in seconds when the API provides it. */
duration?: number | null
duration_seconds?: number | null
@ -457,6 +468,8 @@ export interface TopLevelCourseModuleItem {
description: string
icon?: string | null
sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string
}
@ -507,6 +520,7 @@ export interface TopLevelModuleLessonItem {
description: string
sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
has_practice?: boolean
/** Total length in seconds when the API provides it. */
duration?: number | null
@ -559,6 +573,18 @@ export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
export type PracticePublishStatus = "DRAFT" | "PUBLISHED"
export type ContentAccessTier = "FREE" | "PREMIUM"
/** PUT body when only toggling access_tier on a content resource. */
export interface AccessTierOnlyRequest {
access_tier: ContentAccessTier
}
/** PUT body when only toggling draft/published on a content resource. */
export interface PublishStatusOnlyRequest {
publish_status: PracticePublishStatus
}
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind
@ -1639,6 +1665,11 @@ export interface ReorderItem {
position: number
}
/** Reorder endpoints: PUT with { ordered_ids: number[] } */
export interface ReorderOrderedIdsRequest {
ordered_ids: number[]
}
// Ratings
export interface Rating {
id: number