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, SubCourse,
GetSubCourseEntryAssessmentResponse, GetSubCourseEntryAssessmentResponse,
ReorderItem, ReorderItem,
ReorderOrderedIdsRequest,
GetRatingsResponse, GetRatingsResponse,
GetRatingsParams, GetRatingsParams,
GetVimeoSampleResponse, GetVimeoSampleResponse,
@ -109,6 +110,8 @@ import type {
UpdateParentLinkedPracticeRequest, UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse, UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest, PublishParentLinkedPracticeRequest,
PublishStatusOnlyRequest,
AccessTierOnlyRequest,
UpdateTopLevelModuleLessonRequest, UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest, PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest, CreateTopLevelModuleLessonRequest,
@ -468,6 +471,28 @@ export const getProgramCourses = (
params?: { limit?: number; offset?: number }, params?: { limit?: number; offset?: number },
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params }) ) => 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 = ( export const createProgramCourse = (
programId: number, programId: number,
data: CreateProgramCourseRequest, data: CreateProgramCourseRequest,
@ -597,6 +622,12 @@ export const publishExamPrepModuleLesson = (
data: PublishExamPrepModuleLessonRequest, data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data) ) => 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 */ /** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) => export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`) http.delete(`/exam-prep/lessons/${lessonId}`)
@ -627,6 +658,84 @@ export const deleteExamPrepPractice = (practiceId: number) =>
`/exam-prep/practices/${practiceId}`, `/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 */ /** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data) http.put(`/courses/${courseId}`, data)
@ -690,6 +799,12 @@ export const publishTopLevelModuleLesson = (
data: PublishTopLevelModuleLessonRequest, data: PublishTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data) ) => 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 */ /** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) => export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`) http.delete(`/lessons/${lessonId}`)
@ -725,11 +840,18 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest, data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data) ) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — set publish_status (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) => export const publishParentLinkedPractice = (practiceId: number) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, { setParentLinkedPracticePublishStatus(practiceId, {
publish_status: "PUBLISHED", publish_status: "PUBLISHED",
} satisfies PublishParentLinkedPracticeRequest) })
/** DELETE /practices/:id */ /** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) => 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, ParentContextPractice,
PracticePublishStatus, PracticePublishStatus,
} from "../types/course.types" } from "../types/course.types"
import { isPublishedPublishStatus, normalizePublishStatus } from "./publishStatus"
export function unwrapPracticesList( export function unwrapPracticesList(
res: { res: {
@ -21,19 +22,11 @@ export function unwrapPracticesList(
export function practicePublishStatus( export function practicePublishStatus(
practice: ParentContextPractice, practice: ParentContextPractice,
): PracticePublishStatus | null { ): PracticePublishStatus | null {
const raw = practice.publish_status return normalizePublishStatus(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
} }
export function isPracticePublished(practice: ParentContextPractice): boolean { export function isPracticePublished(practice: ParentContextPractice): boolean {
return practicePublishStatus(practice) === "PUBLISHED" return isPublishedPublishStatus(practice.publish_status)
} }
export function isPracticeDraft(practice: ParentContextPractice): boolean { 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, getPracticesByParentCourse,
getProgramCourses, getProgramCourses,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice, setParentLinkedPracticePublishStatus,
updateParentLinkedPractice, setTopLevelCourseModuleAccessTier,
setTopLevelCourseModulePublishStatus,
updateTopLevelCourseModule, updateTopLevelCourseModule,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
import type { import type {
ParentContextPractice, ParentContextPractice,
ContentAccessTier,
PracticePublishStatus,
ProgramCourseListItem, ProgramCourseListItem,
TopLevelCourseModuleItem, TopLevelCourseModuleItem,
} from "../../types/course.types"; } 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 { import {
isPracticeDraft, filterBySearchAndPublishStatus,
isPracticePublished, type PublishStatusFilter,
unwrapPracticesList, } from "../../lib/contentListFilters";
} from "../../lib/parentContextPractice"; import { unwrapPracticesList } from "../../lib/parentContextPractice";
import { AddModuleModal } from "./components/AddModuleModal"; import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard"; import { ModulePracticeCard } from "./components/ModulePracticeCard";
@ -166,7 +173,12 @@ export function CourseDetailPage() {
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules"); 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 [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false); const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>( const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
@ -175,6 +187,12 @@ export function CourseDetailPage() {
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState< const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null number | null
>(null); >(null);
const [publishStatusModuleId, setPublishStatusModuleId] = useState<
number | null
>(null);
const [accessTierModuleId, setAccessTierModuleId] = useState<number | null>(
null,
);
const openEditModule = (module: TopLevelCourseModuleItem) => { const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module); setEditingModule(module);
@ -309,60 +327,115 @@ export function CourseDetailPage() {
void loadCoursePractices(); void loadCoursePractices();
}, [activeTab, loadCoursePractices]); }, [activeTab, loadCoursePractices]);
const filteredPractices = useMemo(() => { const filteredModules = useMemo(
if (practiceFilter === "Published") { () =>
return practices.filter(isPracticePublished); filterBySearchAndPublishStatus(modules, {
} search: moduleSearch,
if (practiceFilter === "Draft") { publishStatusFilter: modulePublishStatusFilter,
return practices.filter(isPracticeDraft); getSearchFields: (m) => [m.name, m.description],
} getPublishStatus: (m) => m.publish_status,
if (practiceFilter === "Archived") { }),
return []; [modulePublishStatusFilter, moduleSearch, modules],
} );
return practices;
}, [practices, practiceFilter]);
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); setPublishStatusPracticeId(practiceId);
try { try {
await publishParentLinkedPractice(practiceId); await setParentLinkedPracticePublishStatus(practiceId, {
publish_status: nextStatus,
});
setPractices((prev) => setPractices((prev) =>
prev.map((p) => 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) { } catch (e: unknown) {
console.error(e); console.error(e);
const msg = const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data (e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice"; ?.message ?? "Failed to update practice status";
toast.error(msg); toast.error(msg);
} finally { } finally {
setPublishStatusPracticeId(null); setPublishStatusPracticeId(null);
} }
}; };
const handleSavePracticeAsDraft = async (practiceId: number) => { const handleModulePublishStatus = async (
setPublishStatusPracticeId(practiceId); moduleId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusModuleId(moduleId);
try { try {
await updateParentLinkedPractice(practiceId, { await setTopLevelCourseModulePublishStatus(moduleId, {
publish_status: "DRAFT", publish_status: nextStatus,
}); });
setPractices((prev) => setModules((prev) =>
prev.map((p) => prev.map((m) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p, 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) { } catch (e: unknown) {
console.error(e); console.error(e);
const msg = const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data (e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft"; ?.message ?? "Failed to update module status";
toast.error(msg); toast.error(msg);
} finally { } 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"> <h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
{displayTitle} {displayTitle}
</h1> </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} {displayDescription}
</p> </ContentPageDescription>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
@ -625,13 +698,29 @@ export function CourseDetailPage() {
</p> </p>
</div> </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 <div
className="grid justify-start gap-10" className="grid justify-start gap-10"
style={{ style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))", gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}} }}
> >
{modules.map((module, index) => { {filteredModules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? ""; const iconSrc = module.icon?.trim() ?? "";
return ( return (
<Card <Card
@ -667,6 +756,30 @@ export function CourseDetailPage() {
<ModuleIconCircle iconSrc={iconSrc} index={index} /> <ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1"> <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]"> <h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name} {module.name}
</h3> </h3>
@ -708,31 +821,19 @@ export function CourseDetailPage() {
); );
})} })}
</div> </div>
)}
</div>
) )
) : ( ) : (
<div className="space-y-8"> <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"> <ContentListSearchFilterBar
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300"> search={practiceSearch}
STATUS: onSearchChange={setPracticeSearch}
</div> publishStatusFilter={practicePublishStatusFilter}
<div className="flex items-center gap-3"> onPublishStatusFilterChange={setPracticePublishStatusFilter}
{["All", "Published", "Draft", "Archived"].map((label) => ( searchPlaceholder="Search practices by title or description…"
<button searchAriaLabel="Search practices"
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>
{practicesLoading ? ( {practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500"> <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={() => onEdit={() =>
navigate(`/content/practices?type=course&id=${courseIdNum}`) navigate(`/content/practices?type=course&id=${courseIdNum}`)
} }
onPublish={() => void handlePublishPractice(practice.id)} onPublish={() =>
void handlePracticePublishStatus(
practice.id,
"PUBLISHED",
)
}
onSaveAsDraft={() => 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"> <h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
{practices.length === 0 {practices.length === 0
? "No practices for this course yet" ? "No practices for this course yet"
: "No practices match this filter"} : "No practices match your search or status filter"}
</h2> </h2>
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400"> <p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
{practices.length === 0 {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 { Link, useParams, useNavigate } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
@ -28,11 +28,21 @@ import { toast } from "sonner";
import { ResolvedImage } from "../../components/media/ResolvedImage"; import { ResolvedImage } from "../../components/media/ResolvedImage";
import { import {
createExamPrepCatalogUnit, createExamPrepCatalogUnit,
setExamPrepCatalogUnitAccessTier,
setExamPrepCatalogUnitPublishStatus,
updateExamPrepCatalogUnit, updateExamPrepCatalogUnit,
deleteExamPrepCatalogUnit, deleteExamPrepCatalogUnit,
getExamPrepCatalogUnits, getExamPrepCatalogUnits,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.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() { export function CourseManagementPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -55,12 +65,20 @@ export function CourseManagementPage() {
description: string; description: string;
thumbnail: string; thumbnail: string;
sortOrder: number; sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
modules: number; modules: number;
lessons: number; lessons: number;
practices: number; practices: number;
gradient: string; gradient: string;
}> }>
>([]); >([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [unitsLoading, setUnitsLoading] = useState(false); const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null); const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
@ -71,6 +89,20 @@ export function CourseManagementPage() {
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null); const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null); const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
const [deletingUnit, setDeletingUnit] = useState(false); 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 // Mock data for display titles
const courseTitles: Record<string, string> = { const courseTitles: Record<string, string> = {
@ -101,6 +133,8 @@ export function CourseManagementPage() {
description: row.description?.trim() || "—", description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || "", thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
modules: Number(row.modules_count ?? 0), modules: Number(row.modules_count ?? 0),
lessons: Number(row.lessons_count ?? row.videos_count ?? 0), lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0), practices: Number(row.practices_count ?? 0),
@ -125,6 +159,58 @@ export function CourseManagementPage() {
void loadUnits(); void loadUnits();
}, [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) => const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://"); value.startsWith("http://") || value.startsWith("https://");
@ -574,7 +660,18 @@ export function CourseManagementPage() {
</div> </div>
{/* Grid of Units */} {/* 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 ? ( {unitsLoading ? (
<p className="text-sm text-grayScale-500">Loading units...</p> <p className="text-sm text-grayScale-500">Loading units...</p>
) : units.length === 0 ? ( ) : units.length === 0 ? (
@ -586,8 +683,14 @@ export function CourseManagementPage() {
Create your first unit to start organizing modules, lessons, and practices. Create your first unit to start organizing modules, lessons, and practices.
</p> </p>
</div> </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 <Card
key={unit.id} 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" 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="p-4 flex flex-col flex-1 space-y-6">
<div className="space-y-3 flex-1"> <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"> <h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
{unit.name} {unit.name}
</h3> </h3>
@ -679,6 +800,7 @@ export function CourseManagementPage() {
</Card> </Card>
)) ))
)} )}
</div>
</div> </div>
<Dialog <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 { ArrowLeft, Plus, FileText, Video } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@ -24,10 +24,20 @@ import {
deleteExamPrepModuleLesson, deleteExamPrepModuleLesson,
getExamPrepModuleLessons, getExamPrepModuleLessons,
publishExamPrepModuleLesson, publishExamPrepModuleLesson,
setExamPrepModuleLessonAccessTier,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api"; import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview"; import { resolveThumbnailForPreview } from "../../lib/videoPreview";
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 = [ const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]", "from-[#CBD5E1] to-[#94A3B8]",
@ -74,6 +84,7 @@ export function CourseModuleDetailPage() {
thumbnail: string; thumbnail: string;
sortOrder: number; sortOrder: number;
publishStatus: PracticePublishStatus | string | null; publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
durationSeconds: number | null; durationSeconds: number | null;
}> }>
>([]); >([]);
@ -81,6 +92,23 @@ export function CourseModuleDetailPage() {
const [publishStatusLessonId, setPublishStatusLessonId] = useState< const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null number | null
>(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 [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState(""); const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState(""); const [createVideoUrl, setCreateVideoUrl] = useState("");
@ -159,6 +187,7 @@ export function CourseModuleDetailPage() {
thumbnail: row.thumbnail?.trim() || "", thumbnail: row.thumbnail?.trim() || "",
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null, publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
durationSeconds, 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]) => const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`; `/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]"> <h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
{moduleTitle} {moduleTitle}
</h1> </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} {moduleDescription}
</p> </ContentPageDescription>
</div> </div>
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
@ -780,8 +837,24 @@ export function CourseModuleDetailPage() {
{lessonsLoadError} {lessonsLoadError}
</div> </div>
) : lessons.length > 0 ? ( ) : 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"> <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 <VideoCard
key={lesson.id} key={lesson.id}
id={lesson.id} id={lesson.id}
@ -803,9 +876,16 @@ export function CourseModuleDetailPage() {
void handleToggleLessonPublishStatus(lesson.id, nextStatus) void handleToggleLessonPublishStatus(lesson.id, nextStatus)
} }
publishStatusUpdating={publishStatusLessonId === lesson.id} publishStatusUpdating={publishStatusLessonId === lesson.id}
accessTier={lesson.accessTier}
onToggleAccessTier={(nextTier) =>
void handleToggleLessonAccessTier(lesson.id, nextTier)
}
accessTierUpdating={accessTierLessonId === lesson.id}
/> />
))} ))}
</div> </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="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"> <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 { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -21,9 +21,20 @@ import alertSrc from "../../assets/Alert.svg";
import { import {
getLearningPrograms, getLearningPrograms,
createLearningProgram, createLearningProgram,
setLearningProgramAccessTier,
setLearningProgramPublishStatus,
updateLearningProgram, updateLearningProgram,
deleteLearningProgram, deleteLearningProgram,
} from "../../api/courses.api"; } 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 { refreshFileUrl, uploadImageFile } from "../../api/files.api";
import type { LearningProgramListItem } from "../../types/course.types"; import type { LearningProgramListItem } from "../../types/course.types";
@ -71,6 +82,26 @@ export function LearnEnglishPage() {
const [deletingProgram, setDeletingProgram] = const [deletingProgram, setDeletingProgram] =
useState<LearningProgramListItem | null>(null); useState<LearningProgramListItem | null>(null);
const [deleting, setDeleting] = useState(false); 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) => { const openEdit = (program: LearningProgramListItem) => {
setEditingProgram(program); 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 () => { const handleConfirmDelete = async () => {
if (!deletingProgram) return; if (!deletingProgram) return;
setDeleting(true); setDeleting(true);
@ -559,8 +642,29 @@ export function LearnEnglishPage() {
</p> </p>
</div> </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"> <div className="flex flex-wrap gap-10">
{programs.map((program) => ( {filteredPrograms.map((program) => (
<Card <Card
key={program.id} 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" 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]"> <CardContent className="bg-white p-6 flex flex-col h-[280px]">
<div className="flex-1 min-h-0"> <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"> <h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
{program.name} {program.name}
</h3> </h3>
@ -627,6 +749,8 @@ export function LearnEnglishPage() {
</Card> </Card>
))} ))}
</div> </div>
)}
</div>
)} )}
<Dialog <Dialog

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@ -17,6 +17,8 @@ import {
deleteExamPrepPractice, deleteExamPrepPractice,
getExamPrepLessonPractices, getExamPrepLessonPractices,
getPracticesByParentLesson, getPracticesByParentLesson,
setExamPrepPracticePublishStatus,
setParentLinkedPracticePublishStatus,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@ -33,7 +35,14 @@ import type {
GetExamPrepLessonPracticesResponse, GetExamPrepLessonPracticesResponse,
GetPracticesByParentContextResponse, GetPracticesByParentContextResponse,
ParentContextPractice, ParentContextPractice,
PracticePublishStatus,
} from "../../types/course.types"; } 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 { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -83,11 +92,15 @@ function PracticeCard({
index, index,
total, total,
onDelete, onDelete,
onTogglePublishStatus,
publishStatusUpdating,
}: { }: {
practice: ParentContextPractice; practice: ParentContextPractice;
index: number; index: number;
total: number; total: number;
onDelete?: () => void; onDelete?: () => void;
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean;
}) { }) {
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image); const thumb = resolveThumbnailForPreview(practice.story_image);
@ -141,14 +154,12 @@ function PracticeCard({
<Badge variant="secondary" className="font-mono text-[10px] font-semibold"> <Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id} ID {practice.id}
</Badge> </Badge>
{practice.publish_status ? ( <ContentPublishStatusChip
<Badge publishStatus={practice.publish_status}
variant={practice.publish_status === "PUBLISHED" ? "default" : "secondary"} updating={publishStatusUpdating}
className="text-[10px] font-semibold normal-case" contentLabel="practice"
> onToggle={onTogglePublishStatus}
{practice.publish_status} />
</Badge>
) : null}
</div> </div>
{onDelete ? ( {onDelete ? (
<Button <Button
@ -231,6 +242,27 @@ export function LessonPracticesPage() {
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null); const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null);
const [deleting, setDeleting] = useState(false); 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 lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0; 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/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)}`; : `/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 () => { const confirmDeletePractice = async () => {
if (!practiceToDelete) return; if (!practiceToDelete) return;
setDeleting(true); setDeleting(true);
@ -459,17 +524,39 @@ export function LessonPracticesPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-5"> <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 <PracticeCard
key={p.id} key={p.id}
practice={p} practice={p}
index={i} index={i}
total={practices.length} total={filteredPractices.length}
onDelete={ onDelete={
isExamPrep ? () => setPracticeToDelete(p) : undefined isExamPrep ? () => setPracticeToDelete(p) : undefined
} }
publishStatusUpdating={publishStatusUpdatingId === p.id}
onTogglePublishStatus={(nextStatus) =>
void handlePracticePublishStatus(p.id, nextStatus)
}
/> />
))} ))
)}
</div> </div>
)} )}
</div> </div>

View File

@ -7,21 +7,18 @@ import {
getModuleLessons, getModuleLessons,
getPracticesByParentModule, getPracticesByParentModule,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice,
publishTopLevelModuleLesson, publishTopLevelModuleLesson,
updateParentLinkedPractice, setParentLinkedPracticePublishStatus,
setTopLevelModuleLessonAccessTier,
updateTopLevelModuleLesson, updateTopLevelModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
import type { import type {
ContentAccessTier,
ParentContextPractice, ParentContextPractice,
PracticePublishStatus, PracticePublishStatus,
TopLevelModuleLessonItem, TopLevelModuleLessonItem,
} from "../../types/course.types"; } from "../../types/course.types";
import { import { unwrapPracticesList } from "../../lib/parentContextPractice";
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@ -38,6 +35,12 @@ import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField"; import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard"; import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { VideoCard } from "./components/VideoCard"; 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 = [ const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]", "from-[#CBD5E1] to-[#94A3B8]",
@ -61,7 +64,12 @@ export function ModuleDetailPage() {
moduleId: string; moduleId: string;
}>(); }>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); 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 [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true); const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null); const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
@ -82,6 +90,9 @@ export function ModuleDetailPage() {
const [publishStatusLessonId, setPublishStatusLessonId] = useState< const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null number | null
>(null); >(null);
const [accessTierLessonId, setAccessTierLessonId] = useState<number | null>(
null,
);
const [practices, setPractices] = useState<ParentContextPractice[]>([]); const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false); const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>( const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
@ -247,57 +258,56 @@ export function ModuleDetailPage() {
void loadModulePractices(); void loadModulePractices();
}, [activeTab, loadModulePractices]); }, [activeTab, loadModulePractices]);
const filteredPractices = useMemo(() => { const filteredLessons = useMemo(
if (activeFilter === "Published") { () =>
return practices.filter(isPracticePublished); filterBySearchAndPublishStatus(lessons, {
} search: lessonSearch,
if (activeFilter === "Draft") { publishStatusFilter: lessonPublishStatusFilter,
return practices.filter(isPracticeDraft); getSearchFields: (l) => [l.title, l.description],
} getPublishStatus: (l) => l.publish_status,
if (activeFilter === "Archived") { }),
return []; [lessonPublishStatusFilter, lessonSearch, lessons],
} );
return practices;
}, [practices, activeFilter]);
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); setPublishStatusPracticeId(practiceId);
try { try {
await publishParentLinkedPractice(practiceId); await setParentLinkedPracticePublishStatus(practiceId, {
setPractices((prev) => publish_status: nextStatus,
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",
}); });
setPractices((prev) => setPractices((prev) =>
prev.map((p) => 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) { } catch (e: unknown) {
console.error(e); console.error(e);
const msg = const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data (e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft"; ?.message ?? "Failed to update practice status";
toast.error(msg); toast.error(msg);
} finally { } finally {
setPublishStatusPracticeId(null); 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 () => { const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return; if (!deletingLesson) return;
setDeletingLessonInFlight(true); setDeletingLessonInFlight(true);
@ -429,9 +467,9 @@ export function ModuleDetailPage() {
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight"> <h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
{displayModuleName} {displayModuleName}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] max-w-2xl"> <ContentPageDescription className="text-[14px] text-grayScale-500">
{displayModuleDescription} {displayModuleDescription}
</p> </ContentPageDescription>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
@ -502,8 +540,24 @@ export function ModuleDetailPage() {
{lessonsLoadError} {lessonsLoadError}
</div> </div>
) : lessons.length > 0 ? ( ) : 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"> <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 <VideoCard
key={lesson.id} key={lesson.id}
id={lesson.id} id={lesson.id}
@ -537,9 +591,16 @@ export function ModuleDetailPage() {
void handleToggleLessonPublishStatus(lesson.id, nextStatus) void handleToggleLessonPublishStatus(lesson.id, nextStatus)
} }
publishStatusUpdating={publishStatusLessonId === lesson.id} publishStatusUpdating={publishStatusLessonId === lesson.id}
accessTier={lesson.access_tier}
onToggleAccessTier={(nextTier) =>
void handleToggleLessonAccessTier(lesson.id, nextTier)
}
accessTierUpdating={accessTierLessonId === lesson.id}
/> />
))} ))}
</div> </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="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"> <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"> <div className="space-y-8">
{/* Practice Tab Filter Bar */} <ContentListSearchFilterBar
<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"> search={practiceSearch}
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2"> onSearchChange={setPracticeSearch}
STATUS: publishStatusFilter={practicePublishStatusFilter}
</div> onPublishStatusFilterChange={setPracticePublishStatusFilter}
<div className="flex items-center gap-3"> searchPlaceholder="Search practices by title or description…"
{["All", "Published", "Draft", "Archived"].map((label) => ( searchAriaLabel="Search practices"
<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>
{practicesLoading ? ( {practicesLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium"> <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}`, `/content/practices?type=module&id=${moduleId}`,
) )
} }
onPublish={() => void handlePublishPractice(practice.id)} onPublish={() =>
void handlePracticePublishStatus(
practice.id,
"PUBLISHED",
)
}
onSaveAsDraft={() => 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"> <h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
{practices.length === 0 {practices.length === 0
? "No practices in this module yet" ? "No practices in this module yet"
: "No practices match this filter"} : "No practices match your search or status filter"}
</h2> </h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed"> <p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
{practices.length === 0 {practices.length === 0
? "Add a practice to give learners speaking exercises for this module." ? "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> </p>
{practices.length === 0 ? ( {practices.length === 0 ? (
<Button <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 { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -22,11 +22,24 @@ import {
deleteTopLevelCourse, deleteTopLevelCourse,
getLearningPrograms, getLearningPrograms,
getProgramCourses, getProgramCourses,
setProgramCourseAccessTier,
setProgramCoursePublishStatus,
updateTopLevelCourse, updateTopLevelCourse,
} from "../../api/courses.api"; } 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 { uploadImageFile } from "../../api/files.api";
import type { import type {
ContentAccessTier,
LearningProgramListItem, LearningProgramListItem,
PracticePublishStatus,
ProgramCourseListItem, ProgramCourseListItem,
} from "../../types/course.types"; } from "../../types/course.types";
import { PublishPracticeButton } from "./components/PublishPracticeButton"; import { PublishPracticeButton } from "./components/PublishPracticeButton";
@ -64,9 +77,81 @@ export function ProgramCoursesPage() {
const [createSaving, setCreateSaving] = useState(false); const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false); const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null); 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 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 () => { const loadData = useCallback(async () => {
if (!Number.isFinite(programId) || programId < 1) { if (!Number.isFinite(programId) || programId < 1) {
setError("Invalid program"); setError("Invalid program");
@ -342,9 +427,9 @@ export function ProgramCoursesPage() {
{programTitle} {programTitle}
</h1> </h1>
{programDescription ? ( {programDescription ? (
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400"> <ContentPageDescription className="text-[15px] text-grayScale-400">
{programDescription} {programDescription}
</p> </ContentPageDescription>
) : loading ? ( ) : loading ? (
<div className="flex items-center gap-2 pt-1"> <div className="flex items-center gap-2 pt-1">
<img <img
@ -579,8 +664,24 @@ export function ProgramCoursesPage() {
</p> </p>
</div> </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"> <div className="flex flex-wrap gap-10">
{courses.map((course) => { {filteredCourses.map((course) => {
const modules = const modules =
course.module_count ?? course.modules_count ?? 0; course.module_count ?? course.modules_count ?? 0;
const lessons = course.lesson_count ?? course.videos_count ?? 0; const lessons = course.lesson_count ?? course.videos_count ?? 0;
@ -631,6 +732,24 @@ export function ProgramCoursesPage() {
} }
/> />
<CardContent className="p-6"> <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"> <h3 className="text-xl font-bold text-grayScale-700">
{course.name} {course.name}
</h3> </h3>
@ -688,6 +807,8 @@ export function ProgramCoursesPage() {
); );
})} })}
</div> </div>
)}
</div>
)} )}
<Dialog <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 { Link, useParams, useNavigate } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
@ -27,9 +27,21 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
import { import {
createExamPrepCatalogCourse, createExamPrepCatalogCourse,
getExamPrepCatalogCourses, getExamPrepCatalogCourses,
setExamPrepCatalogCourseAccessTier,
setExamPrepCatalogCoursePublishStatus,
updateExamPrepCatalogCourse, updateExamPrepCatalogCourse,
deleteExamPrepCatalogCourse, deleteExamPrepCatalogCourse,
} from "../../api/courses.api"; } 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 { uploadImageFile } from "../../api/files.api";
import uploadIcon from "../../assets/icons/upload.png"; import uploadIcon from "../../assets/icons/upload.png";
@ -50,11 +62,19 @@ export function ProgramDetailPage() {
description: string; description: string;
thumbnail?: string | null; thumbnail?: string | null;
sortOrder: number; sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
unitsCount: number; unitsCount: number;
modulesCount: number; modulesCount: number;
lessonsCount: number; lessonsCount: number;
}[] }[]
>([]); >([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [catalogLoading, setCatalogLoading] = useState(false); const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null); const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
@ -99,6 +119,8 @@ export function ProgramDetailPage() {
description: row.description?.trim() || "—", description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null, thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
publishStatus: row.publish_status ?? null,
accessTier: row.access_tier ?? null,
unitsCount: Number(row.units_count ?? 0), unitsCount: Number(row.units_count ?? 0),
modulesCount: Number(row.modules_count ?? 0), modulesCount: Number(row.modules_count ?? 0),
lessonsCount: Number(row.lessons_count ?? 0), lessonsCount: Number(row.lessons_count ?? 0),
@ -116,21 +138,94 @@ export function ProgramDetailPage() {
useEffect(() => { useEffect(() => {
void loadCatalogCourses(); void loadCatalogCourses();
}, [loadCatalogCourses]); }, [loadCatalogCourses]);
const proficiencyCourses = [ const proficiencyCourses = useMemo(
...currentProgram.courses, () => [
...createdCourses.map((course) => ({ ...currentProgram.courses,
id: course.id, ...createdCourses.map((course) => ({
name: course.name, id: course.id,
description: course.description, name: course.name,
units_count: course.unitsCount, description: course.description,
modules_count: course.modulesCount, units_count: course.unitsCount,
lessons_count: course.lessonsCount, modules_count: course.modulesCount,
logo: null, lessons_count: course.lessonsCount,
thumbnail: course.thumbnail ?? "", logo: null,
sort_order: course.sortOrder, thumbnail: course.thumbnail ?? "",
buttonText: "View Detail", 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) => const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://"); value.startsWith("http://") || value.startsWith("https://");
@ -425,9 +520,9 @@ export function ProgramDetailPage() {
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900"> <h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
{currentProgram.title} {currentProgram.title}
</h1> </h1>
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500"> <ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
{currentProgram.description} {currentProgram.description}
</p> </ContentPageDescription>
</div> </div>
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
@ -582,7 +677,18 @@ export function ProgramDetailPage() {
</div> </div>
{/* Cards Grid */} {/* 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 ? ( {programType === "proficiency" && catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading catalog courses...</p> <p className="text-sm text-grayScale-500">Loading catalog courses...</p>
) : null} ) : null}
@ -598,9 +704,20 @@ export function ProgramDetailPage() {
Create your first exam-prep catalog course to start organizing units, modules, and lessons. Create your first exam-prep catalog course to start organizing units, modules, and lessons.
</p> </p>
</div> </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" (programType === "proficiency"
? proficiencyCourses ? filteredProficiencyCourses
: currentProgram.courses : currentProgram.courses
).map((course: any) => ( ).map((course: any) => (
<Card <Card
@ -650,6 +767,26 @@ export function ProgramDetailPage() {
{/* Content */} {/* Content */}
<div className="space-y-4 pt-2 flex-1"> <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"> <h3 className="text-[18px] font-medium text-grayScale-900">
{course.name} {course.name}
</h3> </h3>
@ -693,6 +830,7 @@ export function ProgramDetailPage() {
</Card> </Card>
)) ))
)} )}
</div>
</div> </div>
<Dialog <Dialog

View File

@ -20,7 +20,8 @@ export function ReorderContentPage() {
</h1> </h1>
<p className="max-w-2xl text-sm text-grayScale-500"> <p className="max-w-2xl text-sm text-grayScale-500">
Drag and drop programs, courses, modules, and lessons to change 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> </p>
</div> </div>
</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 { Link, useParams, useNavigate } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
@ -28,10 +28,20 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
import { import {
createExamPrepUnitModule, createExamPrepUnitModule,
getExamPrepUnitModules, getExamPrepUnitModules,
setExamPrepUnitModuleAccessTier,
setExamPrepUnitModulePublishStatus,
updateExamPrepUnitModule, updateExamPrepUnitModule,
deleteExamPrepUnitModule, deleteExamPrepUnitModule,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.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() { export function UnitManagementPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -70,11 +80,19 @@ export function UnitManagementPage() {
thumbnail: string; thumbnail: string;
icon: string; icon: string;
sortOrder: number; sortOrder: number;
publishStatus: PracticePublishStatus | string | null;
accessTier: ContentAccessTier | string | null;
lessons: number; lessons: number;
practices: number; practices: number;
gradient: string; gradient: string;
}> }>
>([]); >([]);
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
number | null
>(null);
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
number | null
>(null);
const [editingModuleId, setEditingModuleId] = useState<number | null>(null); const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
@ -87,6 +105,20 @@ export function UnitManagementPage() {
const editIconFileInputRef = useRef<HTMLInputElement>(null); const editIconFileInputRef = useRef<HTMLInputElement>(null);
const [deletingModuleId, setDeletingModuleId] = useState<number | null>(null); const [deletingModuleId, setDeletingModuleId] = useState<number | null>(null);
const [deletingModule, setDeletingModule] = useState(false); 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) => const isHttpUrl = (value: string) =>
value.startsWith("http://") || value.startsWith("https://"); value.startsWith("http://") || value.startsWith("https://");
@ -131,6 +163,8 @@ export function UnitManagementPage() {
thumbnail: row.thumbnail?.trim() || "", thumbnail: row.thumbnail?.trim() || "",
icon: row.icon?.trim() || "", icon: row.icon?.trim() || "",
sortOrder: Number(row.sort_order ?? 0), 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), lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
practices: Number(row.practices_count ?? 0), practices: Number(row.practices_count ?? 0),
gradient: gradient:
@ -154,6 +188,58 @@ export function UnitManagementPage() {
void loadModules(); void loadModules();
}, [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 = () => { const clearCreateModuleForm = () => {
setCreateName(""); setCreateName("");
setCreateThumbnail(""); setCreateThumbnail("");
@ -655,7 +741,18 @@ export function UnitManagementPage() {
</div> </div>
{/* Grid of Modules */} {/* 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 ? ( {modulesLoading ? (
<p className="text-sm text-grayScale-500">Loading modules...</p> <p className="text-sm text-grayScale-500">Loading modules...</p>
) : modules.length === 0 ? ( ) : modules.length === 0 ? (
@ -667,8 +764,14 @@ export function UnitManagementPage() {
Create your first module to start organizing lessons and practices. Create your first module to start organizing lessons and practices.
</p> </p>
</div> </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 <Card
key={`${module.id}-${index}`} 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" 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>
<div className="space-y-1"> <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"> <h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
{module.name} {module.name}
</h3> </h3>
@ -771,6 +892,7 @@ export function UnitManagementPage() {
</Card> </Card>
)) ))
)} )}
</div>
</div> </div>
<Dialog <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, Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import { toast } from "sonner";
import { import {
getLearningPrograms, getLearningPrograms,
getProgramCourses, getProgramCourses,
getTopLevelCourseModules, getTopLevelCourseModules,
getModuleLessons, getModuleLessons,
reorderLearningPrograms,
reorderProgramCourses,
reorderTopLevelCourseModules,
reorderModuleLessons,
} from "../../../api/courses.api"; } 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 --- // --- Types ---
export type ItemType = "program" | "course" | "module" | "lesson"; export type ItemType = "program" | "course" | "module" | "lesson";
@ -327,7 +338,9 @@ export function ContentHierarchyList() {
// 1. Fetch Programs // 1. Fetch Programs
const programsRes = await getLearningPrograms(); const programsRes = await getLearningPrograms();
const programData = programsRes.data?.data; const programData = programsRes.data?.data;
const fetchedPrograms: Program[] = (programData?.programs || []).map( const fetchedPrograms: Program[] = sortBySortOrder(
programData?.programs || [],
).map(
(p) => ({ (p) => ({
id: String(p.id), id: String(p.id),
name: p.name, name: p.name,
@ -347,7 +360,7 @@ export function ContentHierarchyList() {
const coursesResults = await Promise.all(coursesPromises); const coursesResults = await Promise.all(coursesPromises);
const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => { const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => {
const courseData = res.data?.data; const courseData = res.data?.data;
return (courseData?.courses || []).map((c) => ({ return sortBySortOrder(courseData?.courses || []).map((c) => ({
id: String(c.id), id: String(c.id),
name: c.name, name: c.name,
thumbnail: c.thumbnail_url || c.thumbnail || undefined, thumbnail: c.thumbnail_url || c.thumbnail || undefined,
@ -367,7 +380,7 @@ export function ContentHierarchyList() {
const modulesResults = await Promise.all(modulesPromises); const modulesResults = await Promise.all(modulesPromises);
const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => { const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => {
const moduleData = res.data?.data; const moduleData = res.data?.data;
return (moduleData?.modules || []).map((m) => ({ return sortBySortOrder(moduleData?.modules || []).map((m) => ({
id: String(m.id), id: String(m.id),
name: m.name, name: m.name,
thumbnail: m.icon || undefined, thumbnail: m.icon || undefined,
@ -387,7 +400,7 @@ export function ContentHierarchyList() {
const lessonsResults = await Promise.all(lessonsPromises); const lessonsResults = await Promise.all(lessonsPromises);
const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => { const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => {
const lessonData = res.data?.data; const lessonData = res.data?.data;
return (lessonData?.lessons || []).map((l) => ({ return sortBySortOrder(lessonData?.lessons || []).map((l) => ({
id: String(l.id), id: String(l.id),
name: l.title, name: l.title,
thumbnail: l.thumbnail || undefined, thumbnail: l.thumbnail || undefined,
@ -410,16 +423,115 @@ export function ContentHierarchyList() {
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] })); setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
}; };
const reorder = <T extends BaseItem>( const toOrderedIds = (items: BaseItem[]) =>
list: T[], items.map((item) => Number(item.id));
setList: React.Dispatch<React.SetStateAction<T[]>>,
activeId: UniqueIdentifier, const reorderSiblings = <T extends BaseItem>(
overId: UniqueIdentifier, 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 siblings = courses.filter((course) => course.programId === programId);
const newIndex = list.findIndex((i) => i.id === String(overId)); const reordered = reorderSiblings(siblings, activeId, overId);
if (oldIndex !== -1 && newIndex !== -1) { if (!reordered) return;
setList(arrayMove(list, oldIndex, newIndex));
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 <DraggableList
items={programs} items={programs}
onReorder={(active, over) => onReorder={(active, over) =>
reorder(programs, setPrograms, active, over) void handleProgramReorder(active, over)
} }
icon={<LayoutGrid className="h-4 w-4" />} icon={<LayoutGrid className="h-4 w-4" />}
onEdit={(id) => handleEdit("program", id)} onEdit={(id) => handleEdit("program", id)}
@ -521,7 +633,7 @@ export function ContentHierarchyList() {
<DraggableList <DraggableList
items={programCourses} items={programCourses}
onReorder={(active, over) => onReorder={(active, over) =>
reorder(courses, setCourses, active, over) void handleCourseReorder(program.id, active, over)
} }
icon={<BookOpen className="h-4 w-4" />} icon={<BookOpen className="h-4 w-4" />}
onEdit={(id) => handleEdit("course", id)} onEdit={(id) => handleEdit("course", id)}
@ -558,7 +670,7 @@ export function ContentHierarchyList() {
<DraggableList <DraggableList
items={courseModules} items={courseModules}
onReorder={(active, over) => onReorder={(active, over) =>
reorder(modules, setModules, active, over) void handleModuleReorder(course.id, active, over)
} }
icon={<Layers className="h-4 w-4" />} icon={<Layers className="h-4 w-4" />}
onEdit={(id) => handleEdit("module", id)} onEdit={(id) => handleEdit("module", id)}
@ -595,7 +707,7 @@ export function ContentHierarchyList() {
<DraggableList <DraggableList
items={moduleLessons} items={moduleLessons}
onReorder={(active, over) => onReorder={(active, over) =>
reorder(lessons, setLessons, active, over) void handleLessonReorder(module.id, active, over)
} }
icon={<PlayCircle className="h-4 w-4" />} icon={<PlayCircle className="h-4 w-4" />}
onEdit={(id) => handleEdit("lesson", id)} 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, DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu"; } from "../../../components/ui/dropdown-menu";
import { ResolvedImage } from "../../../components/media/ResolvedImage"; import { ResolvedImage } from "../../../components/media/ResolvedImage";
import type { ParentContextPractice } from "../../../types/course.types"; import type {
ParentContextPractice,
PracticePublishStatus,
} from "../../../types/course.types";
import { import {
isPracticePublished, isPracticePublished,
practicePublishStatus, practicePublishStatus,
} from "../../../lib/parentContextPractice"; } from "../../../lib/parentContextPractice";
import { resolveThumbnailForPreview } from "../../../lib/videoPreview"; import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
type ModulePracticeCardProps = { type ModulePracticeCardProps = {
practice: ParentContextPractice; practice: ParentContextPractice;
@ -39,117 +43,152 @@ export function ModulePracticeCard({
[practice.story_image], [practice.story_image],
); );
const [thumbFailed, setThumbFailed] = useState(false); const [thumbFailed, setThumbFailed] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStatus, setPendingStatus] =
useState<PracticePublishStatus | null>(null);
useEffect(() => { useEffect(() => {
setThumbFailed(false); setThumbFailed(false);
}, [thumbnailSrc]); }, [thumbnailSrc]);
return ( const requestStatusChange = (
<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"> nextStatus: PracticePublishStatus,
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]"> e?: React.MouseEvent,
{thumbnailSrc && !thumbFailed ? ( ) => {
<ResolvedImage e?.stopPropagation();
src={thumbnailSrc} if (statusUpdating) return;
alt="" setPendingStatus(nextStatus);
className="absolute inset-0 h-full w-full object-cover" setConfirmOpen(true);
onError={() => setThumbFailed(true)} };
/>
) : null}
</div>
<div className="flex flex-1 flex-col space-y-5 p-5"> const confirmStatusChange = () => {
<div className="flex items-center justify-between gap-2"> if (!pendingStatus) return;
<div if (pendingStatus === "PUBLISHED") {
className={cn( onPublish?.();
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider", } else {
isPublished onSaveAsDraft?.();
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]" }
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400", 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 <div
className={cn( className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full", "flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300", isPublished
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
)} )}
/> >
{statusLabel} <div
</div> className={cn(
<DropdownMenu> "h-1.5 w-1.5 flex-shrink-0 rounded-full",
<DropdownMenuTrigger asChild> isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
<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> {statusLabel}
<DropdownMenuContent align="end" className="w-48"> </div>
<DropdownMenuItem <DropdownMenu>
disabled={statusUpdating} <DropdownMenuTrigger asChild>
onClick={(e) => { <Button
e.stopPropagation(); type="button"
if (isPublished) { variant="ghost"
onSaveAsDraft?.(); size="icon"
} else { className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
onPublish?.(); disabled={statusUpdating}
} aria-label={`Practice options: ${practice.title}`}
}} onClick={(e) => e.stopPropagation()}
> >
{isPublished ? "Save as draft" : "Publish practice"} {statusUpdating ? (
</DropdownMenuItem> <Loader2 className="h-4 w-4 animate-spin" />
</DropdownMenuContent> ) : (
</DropdownMenu> <MoreVertical className="h-4 w-4" />
</div> )}
</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]"> <h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
{practice.title} {practice.title}
</h3> </h3>
<div className="mt-auto grid grid-cols-1 gap-2 pt-2"> <div className="mt-auto grid grid-cols-1 gap-2 pt-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50" className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit?.(); onEdit?.();
}} }}
> >
<Edit2 className="mr-1.5 h-3.5 w-3.5" /> <Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit Edit
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={isPublished || statusUpdating} disabled={isPublished || statusUpdating}
className={cn( className={cn(
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all", "h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
isPublished isPublished
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]" ? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
: "bg-brand-500 text-white hover:bg-brand-600", : "bg-brand-500 text-white hover:bg-brand-600",
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!isPublished) onPublish?.(); if (!isPublished) requestStatusChange("PUBLISHED", e);
}} }}
> >
{statusUpdating {statusUpdating
? "Updating…" ? "Updating…"
: isPublished : isPublished
? "Published" ? "Published"
: "Publish"} : "Publish"}
</Button> </Button>
</div>
</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, isDirectVideoFileUrl,
} from "../../../lib/videoPreview"; } from "../../../lib/videoPreview";
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo"; 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( function resolvePublishBadge(
publishStatus?: PracticePublishStatus | string | null, publishStatus?: PracticePublishStatus | string | null,
@ -90,6 +95,9 @@ interface VideoCardProps {
/** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */ /** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void; onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean; 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. */ /** Shown under title on module lesson cards; reserved height keeps grid rows even. */
description?: string | null; description?: string | null;
} }
@ -110,6 +118,9 @@ export function VideoCard({
onViewPractices, onViewPractices,
onTogglePublishStatus, onTogglePublishStatus,
publishStatusUpdating = false, publishStatusUpdating = false,
accessTier,
onToggleAccessTier,
accessTierUpdating = false,
hoverModuleActions = false, hoverModuleActions = false,
description, description,
}: VideoCardProps) { }: VideoCardProps) {
@ -118,6 +129,9 @@ export function VideoCard({
number | null number | null
>(null); >(null);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [pendingPublishStatus, setPendingPublishStatus] =
useState<PracticePublishStatus | null>(null);
/** Iframe players ignore URL limits in many cases — unmount after real time. */ /** Iframe players ignore URL limits in many cases — unmount after real time. */
const [iframeSessionDone, setIframeSessionDone] = useState(false); const [iframeSessionDone, setIframeSessionDone] = useState(false);
const [iframeSessionKey, setIframeSessionKey] = useState(0); const [iframeSessionKey, setIframeSessionKey] = useState(0);
@ -138,6 +152,23 @@ export function VideoCard({
const previewLengthLabel = formatPreviewLength( const previewLengthLabel = formatPreviewLength(
DEFAULT_PREVIEW_MAX_SECONDS, DEFAULT_PREVIEW_MAX_SECONDS,
); );
const requestPublishStatusChange = (
nextStatus: PracticePublishStatus,
e?: React.MouseEvent,
) => {
e?.stopPropagation();
if (publishStatusUpdating) return;
setPendingPublishStatus(nextStatus);
setPublishConfirmOpen(true);
};
const confirmPublishStatusChange = () => {
if (!pendingPublishStatus || !onTogglePublishStatus) return;
onTogglePublishStatus(pendingPublishStatus);
setPublishConfirmOpen(false);
setPendingPublishStatus(null);
};
const publishBadge = resolvePublishBadge( const publishBadge = resolvePublishBadge(
publishStatus, publishStatus,
status, status,
@ -242,6 +273,7 @@ export function VideoCard({
}; };
return ( return (
<>
<div <div
className={cn( 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", "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", "justify-between",
)} )}
> >
{/* Publish status badge */} <div className="flex min-w-0 flex-wrap items-center gap-2">
{publishBadge ? ( {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 <div
className={cn( className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full", "flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]", publishBadge.isPublished
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
)} )}
>
<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} ) : null}
</div> </div>
) : (
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
Lesson
</div>
)}
{hoverModuleActions && onTogglePublishStatus ? ( {hoverModuleActions && onTogglePublishStatus ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -485,11 +526,11 @@ export function VideoCard({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600" 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}`} aria-label={`Lesson options: ${title}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{publishStatusUpdating ? ( {publishStatusUpdating || accessTierUpdating ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-5 w-5 animate-spin" />
) : ( ) : (
<MoreVertical className="h-5 w-5" /> <MoreVertical className="h-5 w-5" />
@ -498,11 +539,11 @@ export function VideoCard({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem <DropdownMenuItem
disabled={publishStatusUpdating} disabled={publishStatusUpdating || accessTierUpdating}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); requestPublishStatusChange(
onTogglePublishStatus(
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED", publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
e,
); );
}} }}
> >
@ -578,5 +619,17 @@ export function VideoCard({
) : null} ) : null}
</div> </div>
</div> </div>
<PublishStatusConfirmDialog
open={publishConfirmOpen}
onOpenChange={(open) => {
setPublishConfirmOpen(open);
if (!open) setPendingPublishStatus(null);
}}
nextStatus={pendingPublishStatus}
contentLabel="lesson"
confirming={publishStatusUpdating}
onConfirm={confirmPublishStatusChange}
/>
</>
); );
} }

View File

@ -24,6 +24,7 @@ import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api"; import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types"; import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner"; import { toast } from "sonner";
import { InviteTeamMemberDialog } from "../role-management/components/InviteTeamMemberDialog";
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { 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 [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
useEffect(() => { const fetchMembers = async () => {
const fetchMembers = async () => {
setLoading(true); setLoading(true);
try { try {
const batchSize = 100; const batchSize = 100;
@ -128,7 +129,8 @@ export function TeamManagementPage() {
} }
}; };
fetchMembers(); useEffect(() => {
void fetchMembers();
}, []); }, []);
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
@ -224,7 +226,7 @@ export function TeamManagementPage() {
</div> </div>
<Button <Button
className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto" 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" /> <Plus className="h-4 w-4" />
Add Team Member Add Team Member
@ -473,6 +475,12 @@ export function TeamManagementPage() {
</div> </div>
{/* Status Update Confirmation Modal */} {/* Status Update Confirmation Modal */}
<InviteTeamMemberDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void fetchMembers()}
/>
{confirmDialog && ( {confirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <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"> <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 description?: string | null
thumbnail?: string | null thumbnail?: string | null
sort_order: number sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string created_at: string
} }
@ -111,6 +113,8 @@ export interface ProgramCourseListItem {
name: string name: string
description: string description: string
sort_order: number sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string created_at: string
thumbnail?: string | null thumbnail?: string | null
/** Some list endpoints may expose the image as `thumbnail_url` instead. */ /** Some list endpoints may expose the image as `thumbnail_url` instead. */
@ -159,6 +163,8 @@ export interface ExamPrepCatalogCourseItem {
units_count?: number units_count?: number
modules_count?: number modules_count?: number
lessons_count?: number lessons_count?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at?: string created_at?: string
updated_at?: string updated_at?: string
} }
@ -212,6 +218,8 @@ export interface ExamPrepCatalogUnitItem {
description?: string | null description?: string | null
thumbnail?: string | null thumbnail?: string | null
sort_order?: number sort_order?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
modules_count?: number modules_count?: number
lessons_count?: number lessons_count?: number
videos_count?: number videos_count?: number
@ -271,6 +279,8 @@ export interface ExamPrepUnitModuleItem {
thumbnail?: string | null thumbnail?: string | null
icon?: string | null icon?: string | null
sort_order?: number sort_order?: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
lessons_count?: number lessons_count?: number
videos_count?: number videos_count?: number
practices_count?: number practices_count?: number
@ -331,6 +341,7 @@ export interface ExamPrepModuleLessonItem {
description?: string | null description?: string | null
sort_order?: number sort_order?: number
publish_status?: PracticePublishStatus | string | null publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
/** Total length in seconds when the API provides it. */ /** Total length in seconds when the API provides it. */
duration?: number | null duration?: number | null
duration_seconds?: number | null duration_seconds?: number | null
@ -457,6 +468,8 @@ export interface TopLevelCourseModuleItem {
description: string description: string
icon?: string | null icon?: string | null
sort_order: number sort_order: number
publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
created_at: string created_at: string
} }
@ -507,6 +520,7 @@ export interface TopLevelModuleLessonItem {
description: string description: string
sort_order: number sort_order: number
publish_status?: PracticePublishStatus | string | null publish_status?: PracticePublishStatus | string | null
access_tier?: ContentAccessTier | string | null
has_practice?: boolean has_practice?: boolean
/** Total length in seconds when the API provides it. */ /** Total length in seconds when the API provides it. */
duration?: number | null duration?: number | null
@ -559,6 +573,18 @@ export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
export type PracticePublishStatus = "DRAFT" | "PUBLISHED" 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). */ /** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
export interface CreateParentLinkedPracticeRequest { export interface CreateParentLinkedPracticeRequest {
parent_kind: PracticeParentKind parent_kind: PracticeParentKind
@ -1639,6 +1665,11 @@ export interface ReorderItem {
position: number position: number
} }
/** Reorder endpoints: PUT with { ordered_ids: number[] } */
export interface ReorderOrderedIdsRequest {
ordered_ids: number[]
}
// Ratings // Ratings
export interface Rating { export interface Rating {
id: number id: number