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:
parent
e0e5577ea8
commit
39312bf509
|
|
@ -62,6 +62,7 @@ import type {
|
|||
SubCourse,
|
||||
GetSubCourseEntryAssessmentResponse,
|
||||
ReorderItem,
|
||||
ReorderOrderedIdsRequest,
|
||||
GetRatingsResponse,
|
||||
GetRatingsParams,
|
||||
GetVimeoSampleResponse,
|
||||
|
|
@ -109,6 +110,8 @@ import type {
|
|||
UpdateParentLinkedPracticeRequest,
|
||||
UpdateParentLinkedPracticeResponse,
|
||||
PublishParentLinkedPracticeRequest,
|
||||
PublishStatusOnlyRequest,
|
||||
AccessTierOnlyRequest,
|
||||
UpdateTopLevelModuleLessonRequest,
|
||||
PublishTopLevelModuleLessonRequest,
|
||||
CreateTopLevelModuleLessonRequest,
|
||||
|
|
@ -468,6 +471,28 @@ export const getProgramCourses = (
|
|||
params?: { limit?: number; offset?: number },
|
||||
) => http.get<GetProgramCoursesResponse>(`/programs/${programId}/courses`, { params })
|
||||
|
||||
/** PUT /programs/reorder */
|
||||
export const reorderLearningPrograms = (data: ReorderOrderedIdsRequest) =>
|
||||
http.put("/programs/reorder", data)
|
||||
|
||||
/** PUT /programs/:programId/courses/reorder */
|
||||
export const reorderProgramCourses = (
|
||||
programId: number,
|
||||
data: ReorderOrderedIdsRequest,
|
||||
) => http.put(`/programs/${programId}/courses/reorder`, data)
|
||||
|
||||
/** PUT /courses/:courseId/modules/reorder */
|
||||
export const reorderTopLevelCourseModules = (
|
||||
courseId: number,
|
||||
data: ReorderOrderedIdsRequest,
|
||||
) => http.put(`/courses/${courseId}/modules/reorder`, data)
|
||||
|
||||
/** PUT /modules/:moduleId/lessons/reorder */
|
||||
export const reorderModuleLessons = (
|
||||
moduleId: number,
|
||||
data: ReorderOrderedIdsRequest,
|
||||
) => http.put(`/modules/${moduleId}/lessons/reorder`, data)
|
||||
|
||||
export const createProgramCourse = (
|
||||
programId: number,
|
||||
data: CreateProgramCourseRequest,
|
||||
|
|
@ -597,6 +622,12 @@ export const publishExamPrepModuleLesson = (
|
|||
data: PublishExamPrepModuleLessonRequest,
|
||||
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
|
||||
|
||||
/** PUT /exam-prep/lessons/:lessonId — set access_tier only. */
|
||||
export const setExamPrepModuleLessonAccessTier = (
|
||||
lessonId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
|
||||
|
||||
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
|
||||
export const deleteExamPrepModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/exam-prep/lessons/${lessonId}`)
|
||||
|
|
@ -627,6 +658,84 @@ export const deleteExamPrepPractice = (practiceId: number) =>
|
|||
`/exam-prep/practices/${practiceId}`,
|
||||
)
|
||||
|
||||
/** PUT /exam-prep/practices/:practiceId — set publish_status only. */
|
||||
export const setExamPrepPracticePublishStatus = (
|
||||
practiceId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/exam-prep/practices/${practiceId}`, data)
|
||||
|
||||
/** PUT /programs/:programId — set publish_status only. */
|
||||
export const setLearningProgramPublishStatus = (
|
||||
programId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/programs/${programId}`, data)
|
||||
|
||||
/** PUT /programs/:programId — set access_tier only. */
|
||||
export const setLearningProgramAccessTier = (
|
||||
programId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/programs/${programId}`, data)
|
||||
|
||||
/** PUT /courses/:courseId — set publish_status only (program-linked course). */
|
||||
export const setProgramCoursePublishStatus = (
|
||||
courseId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/courses/${courseId}`, data)
|
||||
|
||||
/** PUT /courses/:courseId — set access_tier only. */
|
||||
export const setProgramCourseAccessTier = (
|
||||
courseId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/courses/${courseId}`, data)
|
||||
|
||||
/** PUT /exam-prep/catalog-courses/:catalogCourseId — set publish_status only. */
|
||||
export const setExamPrepCatalogCoursePublishStatus = (
|
||||
catalogCourseId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data)
|
||||
|
||||
/** PUT /exam-prep/catalog-courses/:catalogCourseId — set access_tier only. */
|
||||
export const setExamPrepCatalogCourseAccessTier = (
|
||||
catalogCourseId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/exam-prep/catalog-courses/${catalogCourseId}`, data)
|
||||
|
||||
/** PUT /exam-prep/units/:unitId — set publish_status only. */
|
||||
export const setExamPrepCatalogUnitPublishStatus = (
|
||||
unitId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/exam-prep/units/${unitId}`, data)
|
||||
|
||||
/** PUT /exam-prep/units/:unitId — set access_tier only. */
|
||||
export const setExamPrepCatalogUnitAccessTier = (
|
||||
unitId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/exam-prep/units/${unitId}`, data)
|
||||
|
||||
/** PUT /exam-prep/modules/:moduleId — set publish_status only. */
|
||||
export const setExamPrepUnitModulePublishStatus = (
|
||||
moduleId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/exam-prep/modules/${moduleId}`, data)
|
||||
|
||||
/** PUT /exam-prep/modules/:moduleId — set access_tier only. */
|
||||
export const setExamPrepUnitModuleAccessTier = (
|
||||
moduleId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/exam-prep/modules/${moduleId}`, data)
|
||||
|
||||
/** PUT /modules/:moduleId — set publish_status only (Learn English module). */
|
||||
export const setTopLevelCourseModulePublishStatus = (
|
||||
moduleId: number,
|
||||
data: PublishStatusOnlyRequest,
|
||||
) => http.put(`/modules/${moduleId}`, data)
|
||||
|
||||
/** PUT /modules/:moduleId — set access_tier only. */
|
||||
export const setTopLevelCourseModuleAccessTier = (
|
||||
moduleId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/modules/${moduleId}`, data)
|
||||
|
||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||
http.put(`/courses/${courseId}`, data)
|
||||
|
|
@ -690,6 +799,12 @@ export const publishTopLevelModuleLesson = (
|
|||
data: PublishTopLevelModuleLessonRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** PUT /lessons/:id — set access_tier only. */
|
||||
export const setTopLevelModuleLessonAccessTier = (
|
||||
lessonId: number,
|
||||
data: AccessTierOnlyRequest,
|
||||
) => http.put(`/lessons/${lessonId}`, data)
|
||||
|
||||
/** Learn English top-level module lesson — DELETE /lessons/:id */
|
||||
export const deleteTopLevelModuleLesson = (lessonId: number) =>
|
||||
http.delete(`/lessons/${lessonId}`)
|
||||
|
|
@ -725,11 +840,18 @@ export const updateParentLinkedPractice = (
|
|||
data: UpdateParentLinkedPracticeRequest,
|
||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
|
||||
/** PUT /practices/:id — set publish_status only. */
|
||||
export const setParentLinkedPracticePublishStatus = (
|
||||
practiceId: number,
|
||||
data: PublishParentLinkedPracticeRequest,
|
||||
) =>
|
||||
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
||||
|
||||
/** PUT /practices/:id — publish a draft practice. */
|
||||
export const publishParentLinkedPractice = (practiceId: number) =>
|
||||
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
|
||||
setParentLinkedPracticePublishStatus(practiceId, {
|
||||
publish_status: "PUBLISHED",
|
||||
} satisfies PublishParentLinkedPracticeRequest)
|
||||
})
|
||||
|
||||
/** DELETE /practices/:id */
|
||||
export const deleteParentLinkedPractice = (practiceId: number) =>
|
||||
|
|
|
|||
28
src/lib/accessTier.ts
Normal file
28
src/lib/accessTier.ts
Normal 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"
|
||||
}
|
||||
49
src/lib/contentListFilters.ts
Normal file
49
src/lib/contentListFilters.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
} from "../types/course.types"
|
||||
import { isPublishedPublishStatus, normalizePublishStatus } from "./publishStatus"
|
||||
|
||||
export function unwrapPracticesList(
|
||||
res: {
|
||||
|
|
@ -21,19 +22,11 @@ export function unwrapPracticesList(
|
|||
export function practicePublishStatus(
|
||||
practice: ParentContextPractice,
|
||||
): PracticePublishStatus | null {
|
||||
const raw = practice.publish_status
|
||||
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
|
||||
if (typeof raw === "string") {
|
||||
const upper = raw.toUpperCase()
|
||||
if (upper === "DRAFT" || upper === "PUBLISHED") {
|
||||
return upper as PracticePublishStatus
|
||||
}
|
||||
}
|
||||
return null
|
||||
return normalizePublishStatus(practice.publish_status)
|
||||
}
|
||||
|
||||
export function isPracticePublished(practice: ParentContextPractice): boolean {
|
||||
return practicePublishStatus(practice) === "PUBLISHED"
|
||||
return isPublishedPublishStatus(practice.publish_status)
|
||||
}
|
||||
|
||||
export function isPracticeDraft(practice: ParentContextPractice): boolean {
|
||||
|
|
|
|||
28
src/lib/publishStatus.ts
Normal file
28
src/lib/publishStatus.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -29,21 +29,28 @@ import {
|
|||
getPracticesByParentCourse,
|
||||
getProgramCourses,
|
||||
getTopLevelCourseModules,
|
||||
publishParentLinkedPractice,
|
||||
updateParentLinkedPractice,
|
||||
setParentLinkedPracticePublishStatus,
|
||||
setTopLevelCourseModuleAccessTier,
|
||||
setTopLevelCourseModulePublishStatus,
|
||||
updateTopLevelCourseModule,
|
||||
} from "../../api/courses.api";
|
||||
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
|
||||
import type {
|
||||
ParentContextPractice,
|
||||
ContentAccessTier,
|
||||
PracticePublishStatus,
|
||||
ProgramCourseListItem,
|
||||
TopLevelCourseModuleItem,
|
||||
} from "../../types/course.types";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import { ContentPageDescription } from "./components/ContentPageDescription";
|
||||
import {
|
||||
isPracticeDraft,
|
||||
isPracticePublished,
|
||||
unwrapPracticesList,
|
||||
} from "../../lib/parentContextPractice";
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
import { unwrapPracticesList } from "../../lib/parentContextPractice";
|
||||
import { AddModuleModal } from "./components/AddModuleModal";
|
||||
import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
|
||||
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
||||
|
|
@ -166,7 +173,12 @@ export function CourseDetailPage() {
|
|||
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
|
||||
const [practiceFilter, setPracticeFilter] = useState("All");
|
||||
const [moduleSearch, setModuleSearch] = useState("");
|
||||
const [modulePublishStatusFilter, setModulePublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
const [practiceSearch, setPracticeSearch] = useState("");
|
||||
const [practicePublishStatusFilter, setPracticePublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||
const [practicesLoading, setPracticesLoading] = useState(false);
|
||||
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
|
||||
|
|
@ -175,6 +187,12 @@ export function CourseDetailPage() {
|
|||
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [publishStatusModuleId, setPublishStatusModuleId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierModuleId, setAccessTierModuleId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||
setEditingModule(module);
|
||||
|
|
@ -309,60 +327,115 @@ export function CourseDetailPage() {
|
|||
void loadCoursePractices();
|
||||
}, [activeTab, loadCoursePractices]);
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
if (practiceFilter === "Published") {
|
||||
return practices.filter(isPracticePublished);
|
||||
}
|
||||
if (practiceFilter === "Draft") {
|
||||
return practices.filter(isPracticeDraft);
|
||||
}
|
||||
if (practiceFilter === "Archived") {
|
||||
return [];
|
||||
}
|
||||
return practices;
|
||||
}, [practices, practiceFilter]);
|
||||
const filteredModules = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(modules, {
|
||||
search: moduleSearch,
|
||||
publishStatusFilter: modulePublishStatusFilter,
|
||||
getSearchFields: (m) => [m.name, m.description],
|
||||
getPublishStatus: (m) => m.publish_status,
|
||||
}),
|
||||
[modulePublishStatusFilter, moduleSearch, modules],
|
||||
);
|
||||
|
||||
const handlePublishPractice = async (practiceId: number) => {
|
||||
const filteredPractices = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(practices, {
|
||||
search: practiceSearch,
|
||||
publishStatusFilter: practicePublishStatusFilter,
|
||||
getSearchFields: (p) => [
|
||||
p.title,
|
||||
p.story_description,
|
||||
p.quick_tips,
|
||||
],
|
||||
getPublishStatus: (p) => p.publish_status,
|
||||
}),
|
||||
[practicePublishStatusFilter, practiceSearch, practices],
|
||||
);
|
||||
|
||||
const handlePracticePublishStatus = async (
|
||||
practiceId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
try {
|
||||
await publishParentLinkedPractice(practiceId);
|
||||
await setParentLinkedPracticePublishStatus(practiceId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
|
||||
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
|
||||
),
|
||||
);
|
||||
toast.success("Practice published");
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED"
|
||||
? "Practice published"
|
||||
: "Practice saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to publish practice";
|
||||
?.message ?? "Failed to update practice status";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePracticeAsDraft = async (practiceId: number) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
const handleModulePublishStatus = async (
|
||||
moduleId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusModuleId(moduleId);
|
||||
try {
|
||||
await updateParentLinkedPractice(practiceId, {
|
||||
publish_status: "DRAFT",
|
||||
await setTopLevelCourseModulePublishStatus(moduleId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
|
||||
setModules((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === moduleId ? { ...m, publish_status: nextStatus } : m,
|
||||
),
|
||||
);
|
||||
toast.success("Practice saved as draft");
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to save practice as draft";
|
||||
?.message ?? "Failed to update module status";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
setPublishStatusModuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModuleAccessTier = async (
|
||||
moduleId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierModuleId(moduleId);
|
||||
try {
|
||||
await setTopLevelCourseModuleAccessTier(moduleId, {
|
||||
access_tier: nextTier,
|
||||
});
|
||||
setModules((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === moduleId ? { ...m, access_tier: nextTier } : m,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update module access tier";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAccessTierModuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -471,9 +544,9 @@ export function CourseDetailPage() {
|
|||
<h1 className="text-2xl font-medium tracking-tight text-grayScale-900">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm font-medium text-grayScale-500">
|
||||
<ContentPageDescription className="mt-1 text-sm font-medium text-grayScale-500">
|
||||
{displayDescription}
|
||||
</p>
|
||||
</ContentPageDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
|
|
@ -625,13 +698,29 @@ export function CourseDetailPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ContentListSearchFilterBar
|
||||
search={moduleSearch}
|
||||
onSearchChange={setModuleSearch}
|
||||
publishStatusFilter={modulePublishStatusFilter}
|
||||
onPublishStatusFilterChange={setModulePublishStatusFilter}
|
||||
searchPlaceholder="Search modules by name or description…"
|
||||
searchAriaLabel="Search modules"
|
||||
/>
|
||||
{filteredModules.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No modules match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid justify-start gap-10"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
|
||||
}}
|
||||
>
|
||||
{modules.map((module, index) => {
|
||||
{filteredModules.map((module, index) => {
|
||||
const iconSrc = module.icon?.trim() ?? "";
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -667,6 +756,30 @@ export function CourseDetailPage() {
|
|||
<ModuleIconCircle iconSrc={iconSrc} index={index} />
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={module.publish_status}
|
||||
updating={publishStatusModuleId === module.id}
|
||||
contentLabel="module"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleModulePublishStatus(
|
||||
module.id,
|
||||
nextStatus,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={module.access_tier}
|
||||
updating={accessTierModuleId === module.id}
|
||||
contentLabel="module"
|
||||
onToggle={(nextTier) =>
|
||||
void handleModuleAccessTier(
|
||||
module.id,
|
||||
nextTier,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
|
||||
{module.name}
|
||||
</h3>
|
||||
|
|
@ -708,31 +821,19 @@ export function CourseDetailPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
|
||||
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
|
||||
STATUS:
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => setPracticeFilter(label)}
|
||||
className={cn(
|
||||
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
|
||||
practiceFilter === label
|
||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ContentListSearchFilterBar
|
||||
search={practiceSearch}
|
||||
onSearchChange={setPracticeSearch}
|
||||
publishStatusFilter={practicePublishStatusFilter}
|
||||
onPublishStatusFilterChange={setPracticePublishStatusFilter}
|
||||
searchPlaceholder="Search practices by title or description…"
|
||||
searchAriaLabel="Search practices"
|
||||
/>
|
||||
|
||||
{practicesLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
|
||||
|
|
@ -752,9 +853,14 @@ export function CourseDetailPage() {
|
|||
onEdit={() =>
|
||||
navigate(`/content/practices?type=course&id=${courseIdNum}`)
|
||||
}
|
||||
onPublish={() => void handlePublishPractice(practice.id)}
|
||||
onPublish={() =>
|
||||
void handlePracticePublishStatus(
|
||||
practice.id,
|
||||
"PUBLISHED",
|
||||
)
|
||||
}
|
||||
onSaveAsDraft={() =>
|
||||
void handleSavePracticeAsDraft(practice.id)
|
||||
void handlePracticePublishStatus(practice.id, "DRAFT")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -769,7 +875,7 @@ export function CourseDetailPage() {
|
|||
<h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
|
||||
{practices.length === 0
|
||||
? "No practices for this course yet"
|
||||
: "No practices match this filter"}
|
||||
: "No practices match your search or status filter"}
|
||||
</h2>
|
||||
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
|
||||
{practices.length === 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -28,11 +28,21 @@ import { toast } from "sonner";
|
|||
import { ResolvedImage } from "../../components/media/ResolvedImage";
|
||||
import {
|
||||
createExamPrepCatalogUnit,
|
||||
setExamPrepCatalogUnitAccessTier,
|
||||
setExamPrepCatalogUnitPublishStatus,
|
||||
updateExamPrepCatalogUnit,
|
||||
deleteExamPrepCatalogUnit,
|
||||
getExamPrepCatalogUnits,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
|
||||
export function CourseManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -55,12 +65,20 @@ export function CourseManagementPage() {
|
|||
description: string;
|
||||
thumbnail: string;
|
||||
sortOrder: number;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
accessTier: ContentAccessTier | string | null;
|
||||
modules: number;
|
||||
lessons: number;
|
||||
practices: number;
|
||||
gradient: string;
|
||||
}>
|
||||
>([]);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [unitsLoading, setUnitsLoading] = useState(false);
|
||||
const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
|
@ -71,6 +89,20 @@ export function CourseManagementPage() {
|
|||
const editThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [deletingUnitId, setDeletingUnitId] = useState<number | null>(null);
|
||||
const [deletingUnit, setDeletingUnit] = useState(false);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredUnits = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(units, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (u) => [u.name, u.description],
|
||||
getPublishStatus: (u) => u.publishStatus,
|
||||
}),
|
||||
[listSearch, publishStatusFilter, units],
|
||||
);
|
||||
|
||||
// Mock data for display titles
|
||||
const courseTitles: Record<string, string> = {
|
||||
|
|
@ -101,6 +133,8 @@ export function CourseManagementPage() {
|
|||
description: row.description?.trim() || "—",
|
||||
thumbnail: row.thumbnail?.trim() || "",
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
publishStatus: row.publish_status ?? null,
|
||||
accessTier: row.access_tier ?? null,
|
||||
modules: Number(row.modules_count ?? 0),
|
||||
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
|
||||
practices: Number(row.practices_count ?? 0),
|
||||
|
|
@ -125,6 +159,58 @@ export function CourseManagementPage() {
|
|||
void loadUnits();
|
||||
}, [loadUnits]);
|
||||
|
||||
const handleUnitPublishStatus = async (
|
||||
unitId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(unitId);
|
||||
try {
|
||||
await setExamPrepCatalogUnitPublishStatus(unitId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setUnits((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === unitId ? { ...u, publishStatus: nextStatus } : u,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Unit published" : "Unit saved as draft",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update unit status";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnitAccessTier = async (
|
||||
unitId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierUpdatingId(unitId);
|
||||
try {
|
||||
await setExamPrepCatalogUnitAccessTier(unitId, { access_tier: nextTier });
|
||||
setUnits((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === unitId ? { ...u, accessTier: nextTier } : u,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Unit set to Premium" : "Unit set to Free",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update unit access tier";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAccessTierUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isHttpUrl = (value: string) =>
|
||||
value.startsWith("http://") || value.startsWith("https://");
|
||||
|
||||
|
|
@ -574,7 +660,18 @@ export function CourseManagementPage() {
|
|||
</div>
|
||||
|
||||
{/* Grid of Units */}
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
<div className="space-y-4 pt-4">
|
||||
{!unitsLoading && units.length > 0 ? (
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search units by name or description…"
|
||||
searchAriaLabel="Search units"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{unitsLoading ? (
|
||||
<p className="text-sm text-grayScale-500">Loading units...</p>
|
||||
) : units.length === 0 ? (
|
||||
|
|
@ -586,8 +683,14 @@ export function CourseManagementPage() {
|
|||
Create your first unit to start organizing modules, lessons, and practices.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredUnits.length === 0 ? (
|
||||
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No units match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
units.map((unit) => (
|
||||
filteredUnits.map((unit) => (
|
||||
<Card
|
||||
key={unit.id}
|
||||
className="group relative flex w-[400px] flex-col h-full bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
||||
|
|
@ -633,6 +736,24 @@ export function CourseManagementPage() {
|
|||
|
||||
<div className="p-4 flex flex-col flex-1 space-y-6">
|
||||
<div className="space-y-3 flex-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={unit.publishStatus}
|
||||
updating={publishStatusUpdatingId === unit.id}
|
||||
contentLabel="unit"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleUnitPublishStatus(unit.id, nextStatus)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={unit.accessTier}
|
||||
updating={accessTierUpdatingId === unit.id}
|
||||
contentLabel="unit"
|
||||
onToggle={(nextTier) =>
|
||||
void handleUnitAccessTier(unit.id, nextTier)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-[18px] font-medium text-grayScale-900 transition-colors">
|
||||
{unit.name}
|
||||
</h3>
|
||||
|
|
@ -679,6 +800,7 @@ export function CourseManagementPage() {
|
|||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
|
@ -24,10 +24,20 @@ import {
|
|||
deleteExamPrepModuleLesson,
|
||||
getExamPrepModuleLessons,
|
||||
publishExamPrepModuleLesson,
|
||||
setExamPrepModuleLessonAccessTier,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import type { PracticePublishStatus } from "../../types/course.types";
|
||||
import type {
|
||||
ContentAccessTier,
|
||||
PracticePublishStatus,
|
||||
} from "../../types/course.types";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import { ContentPageDescription } from "./components/ContentPageDescription";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
|
|
@ -74,6 +84,7 @@ export function CourseModuleDetailPage() {
|
|||
thumbnail: string;
|
||||
sortOrder: number;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
accessTier: ContentAccessTier | string | null;
|
||||
durationSeconds: number | null;
|
||||
}>
|
||||
>([]);
|
||||
|
|
@ -81,6 +92,23 @@ export function CourseModuleDetailPage() {
|
|||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierLessonId, setAccessTierLessonId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [lessonSearch, setLessonSearch] = useState("");
|
||||
const [lessonPublishStatusFilter, setLessonPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredLessons = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(lessons, {
|
||||
search: lessonSearch,
|
||||
publishStatusFilter: lessonPublishStatusFilter,
|
||||
getSearchFields: (l) => [l.title, l.description],
|
||||
getPublishStatus: (l) => l.publishStatus,
|
||||
}),
|
||||
[lessonPublishStatusFilter, lessonSearch, lessons],
|
||||
);
|
||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
||||
const [createTitle, setCreateTitle] = useState("");
|
||||
const [createVideoUrl, setCreateVideoUrl] = useState("");
|
||||
|
|
@ -159,6 +187,7 @@ export function CourseModuleDetailPage() {
|
|||
thumbnail: row.thumbnail?.trim() || "",
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
publishStatus: row.publish_status ?? null,
|
||||
accessTier: row.access_tier ?? null,
|
||||
durationSeconds,
|
||||
};
|
||||
}),
|
||||
|
|
@ -505,6 +534,34 @@ export function CourseModuleDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleLessonAccessTier = async (
|
||||
lessonId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierLessonId(lessonId);
|
||||
try {
|
||||
await setExamPrepModuleLessonAccessTier(lessonId, {
|
||||
access_tier: nextTier,
|
||||
});
|
||||
setLessons((prev) =>
|
||||
prev.map((l) =>
|
||||
l.id === lessonId ? { ...l, accessTier: nextTier } : l,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update lesson access tier";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAccessTierLessonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
|
||||
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
|
||||
|
||||
|
|
@ -528,9 +585,9 @@ export function CourseModuleDetailPage() {
|
|||
<h1 className="text-[32px] font-extrabold tracking-tight text-[#0D1421]">
|
||||
{moduleTitle}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[16px] font-medium leading-relaxed text-grayScale-400">
|
||||
<ContentPageDescription className="text-[16px] font-medium text-grayScale-400">
|
||||
{moduleDescription}
|
||||
</p>
|
||||
</ContentPageDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
|
|
@ -780,8 +837,24 @@ export function CourseModuleDetailPage() {
|
|||
{lessonsLoadError}
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<ContentListSearchFilterBar
|
||||
search={lessonSearch}
|
||||
onSearchChange={setLessonSearch}
|
||||
publishStatusFilter={lessonPublishStatusFilter}
|
||||
onPublishStatusFilterChange={setLessonPublishStatusFilter}
|
||||
searchPlaceholder="Search lessons by title or description…"
|
||||
searchAriaLabel="Search lessons"
|
||||
/>
|
||||
{filteredLessons.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No lessons match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{lessons.map((lesson, i) => (
|
||||
{filteredLessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
|
|
@ -803,9 +876,16 @@ export function CourseModuleDetailPage() {
|
|||
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
|
||||
}
|
||||
publishStatusUpdating={publishStatusLessonId === lesson.id}
|
||||
accessTier={lesson.accessTier}
|
||||
onToggleAccessTier={(nextTier) =>
|
||||
void handleToggleLessonAccessTier(lesson.id, nextTier)
|
||||
}
|
||||
accessTierUpdating={accessTierLessonId === lesson.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, ArrowRight, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -21,9 +21,20 @@ import alertSrc from "../../assets/Alert.svg";
|
|||
import {
|
||||
getLearningPrograms,
|
||||
createLearningProgram,
|
||||
setLearningProgramAccessTier,
|
||||
setLearningProgramPublishStatus,
|
||||
updateLearningProgram,
|
||||
deleteLearningProgram,
|
||||
} from "../../api/courses.api";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
hasActiveContentFilters,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
|
||||
import type { LearningProgramListItem } from "../../types/course.types";
|
||||
|
||||
|
|
@ -71,6 +82,26 @@ export function LearnEnglishPage() {
|
|||
const [deletingProgram, setDeletingProgram] =
|
||||
useState<LearningProgramListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredPrograms = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(programs, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (p) => [p.name, p.description],
|
||||
getPublishStatus: (p) => p.publish_status,
|
||||
}),
|
||||
[programs, listSearch, publishStatusFilter],
|
||||
);
|
||||
|
||||
const openEdit = (program: LearningProgramListItem) => {
|
||||
setEditingProgram(program);
|
||||
|
|
@ -254,6 +285,58 @@ export function LearnEnglishPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleProgramPublishStatus = async (
|
||||
programId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(programId);
|
||||
try {
|
||||
await setLearningProgramPublishStatus(programId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setPrograms((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === programId ? { ...p, publish_status: nextStatus } : p,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Program published" : "Program saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update program status";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgramAccessTier = async (
|
||||
programId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierUpdatingId(programId);
|
||||
try {
|
||||
await setLearningProgramAccessTier(programId, { access_tier: nextTier });
|
||||
setPrograms((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === programId ? { ...p, access_tier: nextTier } : p,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Program set to Premium" : "Program set to Free",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update program access tier";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAccessTierUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingProgram) return;
|
||||
setDeleting(true);
|
||||
|
|
@ -559,8 +642,29 @@ export function LearnEnglishPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search programs by name or description…"
|
||||
searchAriaLabel="Search programs"
|
||||
/>
|
||||
{filteredPrograms.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No programs match your search or status filter
|
||||
</p>
|
||||
{hasActiveContentFilters(listSearch, publishStatusFilter) ? (
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Try different keywords or clear the publish status filter.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{programs.map((program) => (
|
||||
{filteredPrograms.map((program) => (
|
||||
<Card
|
||||
key={program.id}
|
||||
className="group relative w-[290px] overflow-hidden border-none shadow-soft transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
|
|
@ -604,6 +708,24 @@ export function LearnEnglishPage() {
|
|||
/>
|
||||
<CardContent className="bg-white p-6 flex flex-col h-[280px]">
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={program.publish_status}
|
||||
updating={publishStatusUpdatingId === program.id}
|
||||
contentLabel="program"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleProgramPublishStatus(program.id, nextStatus)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={program.access_tier}
|
||||
updating={accessTierUpdatingId === program.id}
|
||||
contentLabel="program"
|
||||
onToggle={(nextTier) =>
|
||||
void handleProgramAccessTier(program.id, nextTier)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-grayScale-700 line-clamp-2">
|
||||
{program.name}
|
||||
</h3>
|
||||
|
|
@ -627,6 +749,8 @@ export function LearnEnglishPage() {
|
|||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
|
|
@ -17,6 +17,8 @@ import {
|
|||
deleteExamPrepPractice,
|
||||
getExamPrepLessonPractices,
|
||||
getPracticesByParentLesson,
|
||||
setExamPrepPracticePublishStatus,
|
||||
setParentLinkedPracticePublishStatus,
|
||||
} from "../../api/courses.api";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
|
@ -33,7 +35,14 @@ import type {
|
|||
GetExamPrepLessonPracticesResponse,
|
||||
GetPracticesByParentContextResponse,
|
||||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
} from "../../types/course.types";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
|
|
@ -83,11 +92,15 @@ function PracticeCard({
|
|||
index,
|
||||
total,
|
||||
onDelete,
|
||||
onTogglePublishStatus,
|
||||
publishStatusUpdating,
|
||||
}: {
|
||||
practice: ParentContextPractice;
|
||||
index: number;
|
||||
total: number;
|
||||
onDelete?: () => void;
|
||||
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
||||
publishStatusUpdating?: boolean;
|
||||
}) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const thumb = resolveThumbnailForPreview(practice.story_image);
|
||||
|
|
@ -141,14 +154,12 @@ function PracticeCard({
|
|||
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
|
||||
ID {practice.id}
|
||||
</Badge>
|
||||
{practice.publish_status ? (
|
||||
<Badge
|
||||
variant={practice.publish_status === "PUBLISHED" ? "default" : "secondary"}
|
||||
className="text-[10px] font-semibold normal-case"
|
||||
>
|
||||
{practice.publish_status}
|
||||
</Badge>
|
||||
) : null}
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={practice.publish_status}
|
||||
updating={publishStatusUpdating}
|
||||
contentLabel="practice"
|
||||
onToggle={onTogglePublishStatus}
|
||||
/>
|
||||
</div>
|
||||
{onDelete ? (
|
||||
<Button
|
||||
|
|
@ -231,6 +242,27 @@ export function LessonPracticesPage() {
|
|||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredPractices = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(practices, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (p) => [
|
||||
p.title,
|
||||
p.story_description,
|
||||
p.quick_tips,
|
||||
],
|
||||
getPublishStatus: (p) => p.publish_status,
|
||||
}),
|
||||
[listSearch, practices, publishStatusFilter],
|
||||
);
|
||||
|
||||
const lid = lessonId ? Number(lessonId) : NaN;
|
||||
const validLesson = Number.isFinite(lid) && lid > 0;
|
||||
|
|
@ -289,6 +321,39 @@ export function LessonPracticesPage() {
|
|||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
||||
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||
|
||||
const handlePracticePublishStatus = async (
|
||||
practiceId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(practiceId);
|
||||
try {
|
||||
if (isExamPrep) {
|
||||
await setExamPrepPracticePublishStatus(practiceId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
} else {
|
||||
await setParentLinkedPracticePublishStatus(practiceId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
}
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED"
|
||||
? "Practice published"
|
||||
: "Practice saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
toast.error(err.response?.data?.message || "Failed to update practice status");
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeletePractice = async () => {
|
||||
if (!practiceToDelete) return;
|
||||
setDeleting(true);
|
||||
|
|
@ -459,17 +524,39 @@ export function LessonPracticesPage() {
|
|||
</Card>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{practices.map((p, i) => (
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search practices by title or description…"
|
||||
searchAriaLabel="Search practices"
|
||||
/>
|
||||
{filteredPractices.length === 0 ? (
|
||||
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
|
||||
<CardContent className="px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No practices match your search or status filter
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredPractices.map((p, i) => (
|
||||
<PracticeCard
|
||||
key={p.id}
|
||||
practice={p}
|
||||
index={i}
|
||||
total={practices.length}
|
||||
total={filteredPractices.length}
|
||||
onDelete={
|
||||
isExamPrep ? () => setPracticeToDelete(p) : undefined
|
||||
}
|
||||
publishStatusUpdating={publishStatusUpdatingId === p.id}
|
||||
onTogglePublishStatus={(nextStatus) =>
|
||||
void handlePracticePublishStatus(p.id, nextStatus)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,21 +7,18 @@ import {
|
|||
getModuleLessons,
|
||||
getPracticesByParentModule,
|
||||
getTopLevelCourseModules,
|
||||
publishParentLinkedPractice,
|
||||
publishTopLevelModuleLesson,
|
||||
updateParentLinkedPractice,
|
||||
setParentLinkedPracticePublishStatus,
|
||||
setTopLevelModuleLessonAccessTier,
|
||||
updateTopLevelModuleLesson,
|
||||
} from "../../api/courses.api";
|
||||
import type {
|
||||
ContentAccessTier,
|
||||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
TopLevelModuleLessonItem,
|
||||
} from "../../types/course.types";
|
||||
import {
|
||||
isPracticeDraft,
|
||||
isPracticePublished,
|
||||
unwrapPracticesList,
|
||||
} from "../../lib/parentContextPractice";
|
||||
import { unwrapPracticesList } from "../../lib/parentContextPractice";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -38,6 +35,12 @@ import { cn } from "../../lib/utils";
|
|||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
||||
import { VideoCard } from "./components/VideoCard";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import { ContentPageDescription } from "./components/ContentPageDescription";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
|
||||
const LESSON_THUMB_GRADIENTS = [
|
||||
"from-[#CBD5E1] to-[#94A3B8]",
|
||||
|
|
@ -61,7 +64,12 @@ export function ModuleDetailPage() {
|
|||
moduleId: string;
|
||||
}>();
|
||||
const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
|
||||
const [activeFilter, setActiveFilter] = useState("All");
|
||||
const [lessonSearch, setLessonSearch] = useState("");
|
||||
const [lessonPublishStatusFilter, setLessonPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
const [practiceSearch, setPracticeSearch] = useState("");
|
||||
const [practicePublishStatusFilter, setPracticePublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
|
||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
|
||||
|
|
@ -82,6 +90,9 @@ export function ModuleDetailPage() {
|
|||
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierLessonId, setAccessTierLessonId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||
const [practicesLoading, setPracticesLoading] = useState(false);
|
||||
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
|
||||
|
|
@ -247,57 +258,56 @@ export function ModuleDetailPage() {
|
|||
void loadModulePractices();
|
||||
}, [activeTab, loadModulePractices]);
|
||||
|
||||
const filteredPractices = useMemo(() => {
|
||||
if (activeFilter === "Published") {
|
||||
return practices.filter(isPracticePublished);
|
||||
}
|
||||
if (activeFilter === "Draft") {
|
||||
return practices.filter(isPracticeDraft);
|
||||
}
|
||||
if (activeFilter === "Archived") {
|
||||
return [];
|
||||
}
|
||||
return practices;
|
||||
}, [practices, activeFilter]);
|
||||
const filteredLessons = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(lessons, {
|
||||
search: lessonSearch,
|
||||
publishStatusFilter: lessonPublishStatusFilter,
|
||||
getSearchFields: (l) => [l.title, l.description],
|
||||
getPublishStatus: (l) => l.publish_status,
|
||||
}),
|
||||
[lessonPublishStatusFilter, lessonSearch, lessons],
|
||||
);
|
||||
|
||||
const handlePublishPractice = async (practiceId: number) => {
|
||||
const filteredPractices = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(practices, {
|
||||
search: practiceSearch,
|
||||
publishStatusFilter: practicePublishStatusFilter,
|
||||
getSearchFields: (p) => [
|
||||
p.title,
|
||||
p.story_description,
|
||||
p.quick_tips,
|
||||
],
|
||||
getPublishStatus: (p) => p.publish_status,
|
||||
}),
|
||||
[practicePublishStatusFilter, practiceSearch, practices],
|
||||
);
|
||||
|
||||
const handlePracticePublishStatus = async (
|
||||
practiceId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
try {
|
||||
await publishParentLinkedPractice(practiceId);
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
|
||||
),
|
||||
);
|
||||
toast.success("Practice published");
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to publish practice";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePracticeAsDraft = async (practiceId: number) => {
|
||||
setPublishStatusPracticeId(practiceId);
|
||||
try {
|
||||
await updateParentLinkedPractice(practiceId, {
|
||||
publish_status: "DRAFT",
|
||||
await setParentLinkedPracticePublishStatus(practiceId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setPractices((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
|
||||
p.id === practiceId ? { ...p, publish_status: nextStatus } : p,
|
||||
),
|
||||
);
|
||||
toast.success("Practice saved as draft");
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED"
|
||||
? "Practice published"
|
||||
: "Practice saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to save practice as draft";
|
||||
?.message ?? "Failed to update practice status";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusPracticeId(null);
|
||||
|
|
@ -391,6 +401,34 @@ export function ModuleDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleLessonAccessTier = async (
|
||||
lessonId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierLessonId(lessonId);
|
||||
try {
|
||||
await setTopLevelModuleLessonAccessTier(lessonId, {
|
||||
access_tier: nextTier,
|
||||
});
|
||||
setLessons((prev) =>
|
||||
prev.map((l) =>
|
||||
l.id === lessonId ? { ...l, access_tier: nextTier } : l,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Lesson set to Premium" : "Lesson set to Free",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update lesson access tier";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAccessTierLessonId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeleteLesson = async () => {
|
||||
if (!deletingLesson) return;
|
||||
setDeletingLessonInFlight(true);
|
||||
|
|
@ -429,9 +467,9 @@ export function ModuleDetailPage() {
|
|||
<h1 className="text-2xl font-medium text-grayScale-900 tracking-tight">
|
||||
{displayModuleName}
|
||||
</h1>
|
||||
<p className="text-grayScale-500 text-[14px] max-w-2xl">
|
||||
<ContentPageDescription className="text-[14px] text-grayScale-500">
|
||||
{displayModuleDescription}
|
||||
</p>
|
||||
</ContentPageDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
|
|
@ -502,8 +540,24 @@ export function ModuleDetailPage() {
|
|||
{lessonsLoadError}
|
||||
</div>
|
||||
) : lessons.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<ContentListSearchFilterBar
|
||||
search={lessonSearch}
|
||||
onSearchChange={setLessonSearch}
|
||||
publishStatusFilter={lessonPublishStatusFilter}
|
||||
onPublishStatusFilterChange={setLessonPublishStatusFilter}
|
||||
searchPlaceholder="Search lessons by title or description…"
|
||||
searchAriaLabel="Search lessons"
|
||||
/>
|
||||
{filteredLessons.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No lessons match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{lessons.map((lesson, i) => (
|
||||
{filteredLessons.map((lesson, i) => (
|
||||
<VideoCard
|
||||
key={lesson.id}
|
||||
id={lesson.id}
|
||||
|
|
@ -537,9 +591,16 @@ export function ModuleDetailPage() {
|
|||
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
|
||||
}
|
||||
publishStatusUpdating={publishStatusLessonId === lesson.id}
|
||||
accessTier={lesson.access_tier}
|
||||
onToggleAccessTier={(nextTier) =>
|
||||
void handleToggleLessonAccessTier(lesson.id, nextTier)
|
||||
}
|
||||
accessTierUpdating={accessTierLessonId === lesson.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
||||
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
||||
|
|
@ -570,28 +631,14 @@ export function ModuleDetailPage() {
|
|||
)
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Practice Tab Filter Bar */}
|
||||
<div className="bg-white border border-grayScale-100 rounded-2xl p-4 flex items-center gap-10 shadow-sm overflow-x-auto whitespace-nowrap px-8">
|
||||
<div className="flex items-center gap-2 text-[12px] font-bold text-grayScale-300 uppercase tracking-widest mr-2">
|
||||
STATUS:
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{["All", "Published", "Draft", "Archived"].map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setActiveFilter(label)}
|
||||
className={cn(
|
||||
"h-9 px-5 rounded-full text-[13px] font-bold transition-all",
|
||||
activeFilter === label
|
||||
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
|
||||
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ContentListSearchFilterBar
|
||||
search={practiceSearch}
|
||||
onSearchChange={setPracticeSearch}
|
||||
publishStatusFilter={practicePublishStatusFilter}
|
||||
onPublishStatusFilterChange={setPracticePublishStatusFilter}
|
||||
searchPlaceholder="Search practices by title or description…"
|
||||
searchAriaLabel="Search practices"
|
||||
/>
|
||||
|
||||
{practicesLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||
|
|
@ -613,9 +660,14 @@ export function ModuleDetailPage() {
|
|||
`/content/practices?type=module&id=${moduleId}`,
|
||||
)
|
||||
}
|
||||
onPublish={() => void handlePublishPractice(practice.id)}
|
||||
onPublish={() =>
|
||||
void handlePracticePublishStatus(
|
||||
practice.id,
|
||||
"PUBLISHED",
|
||||
)
|
||||
}
|
||||
onSaveAsDraft={() =>
|
||||
void handleSavePracticeAsDraft(practice.id)
|
||||
void handlePracticePublishStatus(practice.id, "DRAFT")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -630,12 +682,12 @@ export function ModuleDetailPage() {
|
|||
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||
{practices.length === 0
|
||||
? "No practices in this module yet"
|
||||
: "No practices match this filter"}
|
||||
: "No practices match your search or status filter"}
|
||||
</h2>
|
||||
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||
{practices.length === 0
|
||||
? "Add a practice to give learners speaking exercises for this module."
|
||||
: "Try another status filter or add a new practice."}
|
||||
: "Try different keywords or clear the publish status filter."}
|
||||
</p>
|
||||
{practices.length === 0 ? (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -22,11 +22,24 @@ import {
|
|||
deleteTopLevelCourse,
|
||||
getLearningPrograms,
|
||||
getProgramCourses,
|
||||
setProgramCourseAccessTier,
|
||||
setProgramCoursePublishStatus,
|
||||
updateTopLevelCourse,
|
||||
} from "../../api/courses.api";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import { ContentPageDescription } from "./components/ContentPageDescription";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
hasActiveContentFilters,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import type {
|
||||
ContentAccessTier,
|
||||
LearningProgramListItem,
|
||||
PracticePublishStatus,
|
||||
ProgramCourseListItem,
|
||||
} from "../../types/course.types";
|
||||
import { PublishPracticeButton } from "./components/PublishPracticeButton";
|
||||
|
|
@ -64,9 +77,81 @@ export function ProgramCoursesPage() {
|
|||
const [createSaving, setCreateSaving] = useState(false);
|
||||
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
|
||||
const createThumbnailFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredCourses = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(courses, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (c) => [c.name, c.description],
|
||||
getPublishStatus: (c) => c.publish_status,
|
||||
}),
|
||||
[courses, listSearch, publishStatusFilter],
|
||||
);
|
||||
|
||||
const programIdValid = Number.isFinite(programId) && programId >= 1;
|
||||
|
||||
const handleCoursePublishStatus = async (
|
||||
courseId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(courseId);
|
||||
try {
|
||||
await setProgramCoursePublishStatus(courseId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setCourses((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === courseId ? { ...c, publish_status: nextStatus } : c,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Course published" : "Course saved as draft",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course status";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCourseAccessTier = async (
|
||||
courseId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierUpdatingId(courseId);
|
||||
try {
|
||||
await setProgramCourseAccessTier(courseId, { access_tier: nextTier });
|
||||
setCourses((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === courseId ? { ...c, access_tier: nextTier } : c,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Course set to Premium" : "Course set to Free",
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course access tier";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setAccessTierUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!Number.isFinite(programId) || programId < 1) {
|
||||
setError("Invalid program");
|
||||
|
|
@ -342,9 +427,9 @@ export function ProgramCoursesPage() {
|
|||
{programTitle}
|
||||
</h1>
|
||||
{programDescription ? (
|
||||
<p className="max-w-2xl text-[15px] leading-relaxed text-grayScale-400">
|
||||
<ContentPageDescription className="text-[15px] text-grayScale-400">
|
||||
{programDescription}
|
||||
</p>
|
||||
</ContentPageDescription>
|
||||
) : loading ? (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<img
|
||||
|
|
@ -579,8 +664,24 @@ export function ProgramCoursesPage() {
|
|||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search courses by name or description…"
|
||||
searchAriaLabel="Search courses"
|
||||
/>
|
||||
{filteredCourses.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No courses match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-10">
|
||||
{courses.map((course) => {
|
||||
{filteredCourses.map((course) => {
|
||||
const modules =
|
||||
course.module_count ?? course.modules_count ?? 0;
|
||||
const lessons = course.lesson_count ?? course.videos_count ?? 0;
|
||||
|
|
@ -631,6 +732,24 @@ export function ProgramCoursesPage() {
|
|||
}
|
||||
/>
|
||||
<CardContent className="p-6">
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={course.publish_status}
|
||||
updating={publishStatusUpdatingId === course.id}
|
||||
contentLabel="course"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleCoursePublishStatus(course.id, nextStatus)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={course.access_tier}
|
||||
updating={accessTierUpdatingId === course.id}
|
||||
contentLabel="course"
|
||||
onToggle={(nextTier) =>
|
||||
void handleCourseAccessTier(course.id, nextTier)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-grayScale-700">
|
||||
{course.name}
|
||||
</h3>
|
||||
|
|
@ -688,6 +807,8 @@ export function ProgramCoursesPage() {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -27,9 +27,21 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|||
import {
|
||||
createExamPrepCatalogCourse,
|
||||
getExamPrepCatalogCourses,
|
||||
setExamPrepCatalogCourseAccessTier,
|
||||
setExamPrepCatalogCoursePublishStatus,
|
||||
updateExamPrepCatalogCourse,
|
||||
deleteExamPrepCatalogCourse,
|
||||
} from "../../api/courses.api";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import { ContentPageDescription } from "./components/ContentPageDescription";
|
||||
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
hasActiveContentFilters,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import uploadIcon from "../../assets/icons/upload.png";
|
||||
|
||||
|
|
@ -50,11 +62,19 @@ export function ProgramDetailPage() {
|
|||
description: string;
|
||||
thumbnail?: string | null;
|
||||
sortOrder: number;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
accessTier: ContentAccessTier | string | null;
|
||||
unitsCount: number;
|
||||
modulesCount: number;
|
||||
lessonsCount: number;
|
||||
}[]
|
||||
>([]);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
|
@ -99,6 +119,8 @@ export function ProgramDetailPage() {
|
|||
description: row.description?.trim() || "—",
|
||||
thumbnail: row.thumbnail?.trim() || null,
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
publishStatus: row.publish_status ?? null,
|
||||
accessTier: row.access_tier ?? null,
|
||||
unitsCount: Number(row.units_count ?? 0),
|
||||
modulesCount: Number(row.modules_count ?? 0),
|
||||
lessonsCount: Number(row.lessons_count ?? 0),
|
||||
|
|
@ -116,21 +138,94 @@ export function ProgramDetailPage() {
|
|||
useEffect(() => {
|
||||
void loadCatalogCourses();
|
||||
}, [loadCatalogCourses]);
|
||||
const proficiencyCourses = [
|
||||
...currentProgram.courses,
|
||||
...createdCourses.map((course) => ({
|
||||
id: course.id,
|
||||
name: course.name,
|
||||
description: course.description,
|
||||
units_count: course.unitsCount,
|
||||
modules_count: course.modulesCount,
|
||||
lessons_count: course.lessonsCount,
|
||||
logo: null,
|
||||
thumbnail: course.thumbnail ?? "",
|
||||
sort_order: course.sortOrder,
|
||||
buttonText: "View Detail",
|
||||
})),
|
||||
];
|
||||
const proficiencyCourses = useMemo(
|
||||
() => [
|
||||
...currentProgram.courses,
|
||||
...createdCourses.map((course) => ({
|
||||
id: course.id,
|
||||
name: course.name,
|
||||
description: course.description,
|
||||
units_count: course.unitsCount,
|
||||
modules_count: course.modulesCount,
|
||||
lessons_count: course.lessonsCount,
|
||||
logo: null,
|
||||
thumbnail: course.thumbnail ?? "",
|
||||
sort_order: course.sortOrder,
|
||||
publish_status: course.publishStatus,
|
||||
access_tier: course.accessTier,
|
||||
buttonText: "View Detail",
|
||||
})),
|
||||
],
|
||||
[createdCourses, currentProgram.courses],
|
||||
);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredProficiencyCourses = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(proficiencyCourses, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (c) => [c.name, c.description],
|
||||
getPublishStatus: (c) => c.publish_status,
|
||||
}),
|
||||
[listSearch, proficiencyCourses, publishStatusFilter],
|
||||
);
|
||||
|
||||
const handleCoursePublishStatus = async (
|
||||
courseId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(courseId);
|
||||
try {
|
||||
await setExamPrepCatalogCoursePublishStatus(courseId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setCreatedCourses((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === courseId ? { ...c, publishStatus: nextStatus } : c,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Course published" : "Course saved as draft",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course status";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCourseAccessTier = async (
|
||||
courseId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierUpdatingId(courseId);
|
||||
try {
|
||||
await setExamPrepCatalogCourseAccessTier(courseId, {
|
||||
access_tier: nextTier,
|
||||
});
|
||||
setCreatedCourses((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === courseId ? { ...c, accessTier: nextTier } : c,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Course set to Premium" : "Course set to Free",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update course access tier";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAccessTierUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isHttpUrl = (value: string) =>
|
||||
value.startsWith("http://") || value.startsWith("https://");
|
||||
|
|
@ -425,9 +520,9 @@ export function ProgramDetailPage() {
|
|||
<h1 className="text-[26px] font-medium tracking-tight text-grayScale-900">
|
||||
{currentProgram.title}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||
<ContentPageDescription className="text-[15px] font-medium text-grayScale-500">
|
||||
{currentProgram.description}
|
||||
</p>
|
||||
</ContentPageDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
|
|
@ -582,7 +677,18 @@ export function ProgramDetailPage() {
|
|||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
<div className="flex flex-wrap gap-8 mt-10">
|
||||
<div className="mt-10 space-y-6">
|
||||
{programType === "proficiency" && !catalogLoading && proficiencyCourses.length > 0 ? (
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search courses by name or description…"
|
||||
searchAriaLabel="Search catalog courses"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{programType === "proficiency" && catalogLoading ? (
|
||||
<p className="text-sm text-grayScale-500">Loading catalog courses...</p>
|
||||
) : null}
|
||||
|
|
@ -598,9 +704,20 @@ export function ProgramDetailPage() {
|
|||
Create your first exam-prep catalog course to start organizing units, modules, and lessons.
|
||||
</p>
|
||||
</div>
|
||||
) : programType === "proficiency" && filteredProficiencyCourses.length === 0 ? (
|
||||
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No courses match your search or status filter
|
||||
</p>
|
||||
{hasActiveContentFilters(listSearch, publishStatusFilter) ? (
|
||||
<p className="mt-1 text-sm text-grayScale-400">
|
||||
Try different keywords or clear the publish status filter.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
(programType === "proficiency"
|
||||
? proficiencyCourses
|
||||
? filteredProficiencyCourses
|
||||
: currentProgram.courses
|
||||
).map((course: any) => (
|
||||
<Card
|
||||
|
|
@ -650,6 +767,26 @@ export function ProgramDetailPage() {
|
|||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 pt-2 flex-1">
|
||||
{programType === "proficiency" ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={course.publish_status}
|
||||
updating={publishStatusUpdatingId === Number(course.id)}
|
||||
contentLabel="course"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleCoursePublishStatus(Number(course.id), nextStatus)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={course.access_tier}
|
||||
updating={accessTierUpdatingId === Number(course.id)}
|
||||
contentLabel="course"
|
||||
onToggle={(nextTier) =>
|
||||
void handleCourseAccessTier(Number(course.id), nextTier)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="text-[18px] font-medium text-grayScale-900">
|
||||
{course.name}
|
||||
</h3>
|
||||
|
|
@ -693,6 +830,7 @@ export function ProgramDetailPage() {
|
|||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export function ReorderContentPage() {
|
|||
</h1>
|
||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||
Drag and drop programs, courses, modules, and lessons to change
|
||||
their display order.
|
||||
their display order. Changes are saved automatically when you drop an
|
||||
item.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -28,10 +28,20 @@ import { ResolvedImage } from "../../components/media/ResolvedImage";
|
|||
import {
|
||||
createExamPrepUnitModule,
|
||||
getExamPrepUnitModules,
|
||||
setExamPrepUnitModuleAccessTier,
|
||||
setExamPrepUnitModulePublishStatus,
|
||||
updateExamPrepUnitModule,
|
||||
deleteExamPrepUnitModule,
|
||||
} from "../../api/courses.api";
|
||||
import { uploadImageFile } from "../../api/files.api";
|
||||
import { ContentPublishStatusChip } from "./components/ContentPublishStatusChip";
|
||||
import { ContentAccessTierChip } from "./components/ContentAccessTierChip";
|
||||
import { ContentListSearchFilterBar } from "./components/ContentListSearchFilterBar";
|
||||
import type { ContentAccessTier, PracticePublishStatus } from "../../types/course.types";
|
||||
import {
|
||||
filterBySearchAndPublishStatus,
|
||||
type PublishStatusFilter,
|
||||
} from "../../lib/contentListFilters";
|
||||
|
||||
export function UnitManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -70,11 +80,19 @@ export function UnitManagementPage() {
|
|||
thumbnail: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
publishStatus: PracticePublishStatus | string | null;
|
||||
accessTier: ContentAccessTier | string | null;
|
||||
lessons: number;
|
||||
practices: number;
|
||||
gradient: string;
|
||||
}>
|
||||
>([]);
|
||||
const [publishStatusUpdatingId, setPublishStatusUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [accessTierUpdatingId, setAccessTierUpdatingId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editThumbnail, setEditThumbnail] = useState("");
|
||||
|
|
@ -87,6 +105,20 @@ export function UnitManagementPage() {
|
|||
const editIconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [deletingModuleId, setDeletingModuleId] = useState<number | null>(null);
|
||||
const [deletingModule, setDeletingModule] = useState(false);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [publishStatusFilter, setPublishStatusFilter] =
|
||||
useState<PublishStatusFilter>("all");
|
||||
|
||||
const filteredModules = useMemo(
|
||||
() =>
|
||||
filterBySearchAndPublishStatus(modules, {
|
||||
search: listSearch,
|
||||
publishStatusFilter,
|
||||
getSearchFields: (m) => [m.name, m.description],
|
||||
getPublishStatus: (m) => m.publishStatus,
|
||||
}),
|
||||
[listSearch, modules, publishStatusFilter],
|
||||
);
|
||||
|
||||
const isHttpUrl = (value: string) =>
|
||||
value.startsWith("http://") || value.startsWith("https://");
|
||||
|
|
@ -131,6 +163,8 @@ export function UnitManagementPage() {
|
|||
thumbnail: row.thumbnail?.trim() || "",
|
||||
icon: row.icon?.trim() || "",
|
||||
sortOrder: Number(row.sort_order ?? 0),
|
||||
publishStatus: row.publish_status ?? null,
|
||||
accessTier: row.access_tier ?? null,
|
||||
lessons: Number(row.lessons_count ?? row.videos_count ?? 0),
|
||||
practices: Number(row.practices_count ?? 0),
|
||||
gradient:
|
||||
|
|
@ -154,6 +188,58 @@ export function UnitManagementPage() {
|
|||
void loadModules();
|
||||
}, [loadModules]);
|
||||
|
||||
const handleModulePublishStatus = async (
|
||||
moduleId: number,
|
||||
nextStatus: PracticePublishStatus,
|
||||
) => {
|
||||
setPublishStatusUpdatingId(moduleId);
|
||||
try {
|
||||
await setExamPrepUnitModulePublishStatus(moduleId, {
|
||||
publish_status: nextStatus,
|
||||
});
|
||||
setModules((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === moduleId ? { ...m, publishStatus: nextStatus } : m,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextStatus === "PUBLISHED" ? "Module published" : "Module saved as draft",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update module status";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setPublishStatusUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModuleAccessTier = async (
|
||||
moduleId: number,
|
||||
nextTier: ContentAccessTier,
|
||||
) => {
|
||||
setAccessTierUpdatingId(moduleId);
|
||||
try {
|
||||
await setExamPrepUnitModuleAccessTier(moduleId, { access_tier: nextTier });
|
||||
setModules((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === moduleId ? { ...m, accessTier: nextTier } : m,
|
||||
),
|
||||
);
|
||||
toast.success(
|
||||
nextTier === "PREMIUM" ? "Module set to Premium" : "Module set to Free",
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update module access tier";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAccessTierUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCreateModuleForm = () => {
|
||||
setCreateName("");
|
||||
setCreateThumbnail("");
|
||||
|
|
@ -655,7 +741,18 @@ export function UnitManagementPage() {
|
|||
</div>
|
||||
|
||||
{/* Grid of Modules */}
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
<div className="space-y-4 pt-4">
|
||||
{!modulesLoading && modules.length > 0 ? (
|
||||
<ContentListSearchFilterBar
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
publishStatusFilter={publishStatusFilter}
|
||||
onPublishStatusFilterChange={setPublishStatusFilter}
|
||||
searchPlaceholder="Search modules by name or description…"
|
||||
searchAriaLabel="Search modules"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{modulesLoading ? (
|
||||
<p className="text-sm text-grayScale-500">Loading modules...</p>
|
||||
) : modules.length === 0 ? (
|
||||
|
|
@ -667,8 +764,14 @@ export function UnitManagementPage() {
|
|||
Create your first module to start organizing lessons and practices.
|
||||
</p>
|
||||
</div>
|
||||
) : filteredModules.length === 0 ? (
|
||||
<div className="w-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
|
||||
<p className="text-sm font-medium text-grayScale-600">
|
||||
No modules match your search or status filter
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
modules.map((module, index) => (
|
||||
filteredModules.map((module, index) => (
|
||||
<Card
|
||||
key={`${module.id}-${index}`}
|
||||
className="group relative flex w-[400px] flex-col bg-white rounded-[12px] border border-grayScale-100 overflow-hidden shadow-sm hover:shadow-md transition-all"
|
||||
|
|
@ -730,6 +833,24 @@ export function UnitManagementPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2">
|
||||
<ContentPublishStatusChip
|
||||
publishStatus={module.publishStatus}
|
||||
updating={publishStatusUpdatingId === module.id}
|
||||
contentLabel="module"
|
||||
onToggle={(nextStatus) =>
|
||||
void handleModulePublishStatus(module.id, nextStatus)
|
||||
}
|
||||
/>
|
||||
<ContentAccessTierChip
|
||||
accessTier={module.accessTier}
|
||||
updating={accessTierUpdatingId === module.id}
|
||||
contentLabel="module"
|
||||
onToggle={(nextTier) =>
|
||||
void handleModuleAccessTier(module.id, nextTier)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-[16px] font-medium text-grayScale-900 leading-tight">
|
||||
{module.name}
|
||||
</h3>
|
||||
|
|
@ -771,6 +892,7 @@ export function UnitManagementPage() {
|
|||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -36,13 +36,24 @@ import {
|
|||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getLearningPrograms,
|
||||
getProgramCourses,
|
||||
getTopLevelCourseModules,
|
||||
getModuleLessons,
|
||||
reorderLearningPrograms,
|
||||
reorderProgramCourses,
|
||||
reorderTopLevelCourseModules,
|
||||
reorderModuleLessons,
|
||||
} from "../../../api/courses.api";
|
||||
|
||||
function sortBySortOrder<T extends { sort_order?: number }>(items: T[]): T[] {
|
||||
return [...items].sort(
|
||||
(a, b) => Number(a.sort_order ?? 0) - Number(b.sort_order ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
export type ItemType = "program" | "course" | "module" | "lesson";
|
||||
|
||||
|
|
@ -327,7 +338,9 @@ export function ContentHierarchyList() {
|
|||
// 1. Fetch Programs
|
||||
const programsRes = await getLearningPrograms();
|
||||
const programData = programsRes.data?.data;
|
||||
const fetchedPrograms: Program[] = (programData?.programs || []).map(
|
||||
const fetchedPrograms: Program[] = sortBySortOrder(
|
||||
programData?.programs || [],
|
||||
).map(
|
||||
(p) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
|
|
@ -347,7 +360,7 @@ export function ContentHierarchyList() {
|
|||
const coursesResults = await Promise.all(coursesPromises);
|
||||
const fetchedCourses: Course[] = coursesResults.flatMap((res, idx) => {
|
||||
const courseData = res.data?.data;
|
||||
return (courseData?.courses || []).map((c) => ({
|
||||
return sortBySortOrder(courseData?.courses || []).map((c) => ({
|
||||
id: String(c.id),
|
||||
name: c.name,
|
||||
thumbnail: c.thumbnail_url || c.thumbnail || undefined,
|
||||
|
|
@ -367,7 +380,7 @@ export function ContentHierarchyList() {
|
|||
const modulesResults = await Promise.all(modulesPromises);
|
||||
const fetchedModules: Module[] = modulesResults.flatMap((res, idx) => {
|
||||
const moduleData = res.data?.data;
|
||||
return (moduleData?.modules || []).map((m) => ({
|
||||
return sortBySortOrder(moduleData?.modules || []).map((m) => ({
|
||||
id: String(m.id),
|
||||
name: m.name,
|
||||
thumbnail: m.icon || undefined,
|
||||
|
|
@ -387,7 +400,7 @@ export function ContentHierarchyList() {
|
|||
const lessonsResults = await Promise.all(lessonsPromises);
|
||||
const fetchedLessons: Lesson[] = lessonsResults.flatMap((res, idx) => {
|
||||
const lessonData = res.data?.data;
|
||||
return (lessonData?.lessons || []).map((l) => ({
|
||||
return sortBySortOrder(lessonData?.lessons || []).map((l) => ({
|
||||
id: String(l.id),
|
||||
name: l.title,
|
||||
thumbnail: l.thumbnail || undefined,
|
||||
|
|
@ -410,16 +423,115 @@ export function ContentHierarchyList() {
|
|||
setOpenSections((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const reorder = <T extends BaseItem>(
|
||||
list: T[],
|
||||
setList: React.Dispatch<React.SetStateAction<T[]>>,
|
||||
activeId: UniqueIdentifier,
|
||||
overId: UniqueIdentifier,
|
||||
const toOrderedIds = (items: BaseItem[]) =>
|
||||
items.map((item) => Number(item.id));
|
||||
|
||||
const reorderSiblings = <T extends BaseItem>(
|
||||
siblings: T[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
): T[] | null => {
|
||||
const oldIndex = siblings.findIndex((i) => i.id === activeId);
|
||||
const newIndex = siblings.findIndex((i) => i.id === overId);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
|
||||
return null;
|
||||
}
|
||||
return arrayMove(siblings, oldIndex, newIndex);
|
||||
};
|
||||
|
||||
const reorderErrorMessage = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
return typeof message === "string" && message.trim() ? message : fallback;
|
||||
};
|
||||
|
||||
const handleProgramReorder = async (activeId: string, overId: string) => {
|
||||
const reordered = reorderSiblings(programs, activeId, overId);
|
||||
if (!reordered) return;
|
||||
|
||||
const previous = programs;
|
||||
setPrograms(reordered);
|
||||
try {
|
||||
await reorderLearningPrograms({ ordered_ids: toOrderedIds(reordered) });
|
||||
toast.success("Programs reordered");
|
||||
} catch (error) {
|
||||
setPrograms(previous);
|
||||
toast.error(reorderErrorMessage(error, "Failed to reorder programs"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCourseReorder = async (
|
||||
programId: string,
|
||||
activeId: string,
|
||||
overId: string,
|
||||
) => {
|
||||
const oldIndex = list.findIndex((i) => i.id === String(activeId));
|
||||
const newIndex = list.findIndex((i) => i.id === String(overId));
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
setList(arrayMove(list, oldIndex, newIndex));
|
||||
const siblings = courses.filter((course) => course.programId === programId);
|
||||
const reordered = reorderSiblings(siblings, activeId, overId);
|
||||
if (!reordered) return;
|
||||
|
||||
const previous = courses;
|
||||
setCourses((prev) => [
|
||||
...prev.filter((course) => course.programId !== programId),
|
||||
...reordered,
|
||||
]);
|
||||
try {
|
||||
await reorderProgramCourses(Number(programId), {
|
||||
ordered_ids: toOrderedIds(reordered),
|
||||
});
|
||||
toast.success("Courses reordered");
|
||||
} catch (error) {
|
||||
setCourses(previous);
|
||||
toast.error(reorderErrorMessage(error, "Failed to reorder courses"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModuleReorder = async (
|
||||
courseId: string,
|
||||
activeId: string,
|
||||
overId: string,
|
||||
) => {
|
||||
const siblings = modules.filter((module) => module.courseId === courseId);
|
||||
const reordered = reorderSiblings(siblings, activeId, overId);
|
||||
if (!reordered) return;
|
||||
|
||||
const previous = modules;
|
||||
setModules((prev) => [
|
||||
...prev.filter((module) => module.courseId !== courseId),
|
||||
...reordered,
|
||||
]);
|
||||
try {
|
||||
await reorderTopLevelCourseModules(Number(courseId), {
|
||||
ordered_ids: toOrderedIds(reordered),
|
||||
});
|
||||
toast.success("Modules reordered");
|
||||
} catch (error) {
|
||||
setModules(previous);
|
||||
toast.error(reorderErrorMessage(error, "Failed to reorder modules"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLessonReorder = async (
|
||||
moduleId: string,
|
||||
activeId: string,
|
||||
overId: string,
|
||||
) => {
|
||||
const siblings = lessons.filter((lesson) => lesson.moduleId === moduleId);
|
||||
const reordered = reorderSiblings(siblings, activeId, overId);
|
||||
if (!reordered) return;
|
||||
|
||||
const previous = lessons;
|
||||
setLessons((prev) => [
|
||||
...prev.filter((lesson) => lesson.moduleId !== moduleId),
|
||||
...reordered,
|
||||
]);
|
||||
try {
|
||||
await reorderModuleLessons(Number(moduleId), {
|
||||
ordered_ids: toOrderedIds(reordered),
|
||||
});
|
||||
toast.success("Lessons reordered");
|
||||
} catch (error) {
|
||||
setLessons(previous);
|
||||
toast.error(reorderErrorMessage(error, "Failed to reorder lessons"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -487,7 +599,7 @@ export function ContentHierarchyList() {
|
|||
<DraggableList
|
||||
items={programs}
|
||||
onReorder={(active, over) =>
|
||||
reorder(programs, setPrograms, active, over)
|
||||
void handleProgramReorder(active, over)
|
||||
}
|
||||
icon={<LayoutGrid className="h-4 w-4" />}
|
||||
onEdit={(id) => handleEdit("program", id)}
|
||||
|
|
@ -521,7 +633,7 @@ export function ContentHierarchyList() {
|
|||
<DraggableList
|
||||
items={programCourses}
|
||||
onReorder={(active, over) =>
|
||||
reorder(courses, setCourses, active, over)
|
||||
void handleCourseReorder(program.id, active, over)
|
||||
}
|
||||
icon={<BookOpen className="h-4 w-4" />}
|
||||
onEdit={(id) => handleEdit("course", id)}
|
||||
|
|
@ -558,7 +670,7 @@ export function ContentHierarchyList() {
|
|||
<DraggableList
|
||||
items={courseModules}
|
||||
onReorder={(active, over) =>
|
||||
reorder(modules, setModules, active, over)
|
||||
void handleModuleReorder(course.id, active, over)
|
||||
}
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
onEdit={(id) => handleEdit("module", id)}
|
||||
|
|
@ -595,7 +707,7 @@ export function ContentHierarchyList() {
|
|||
<DraggableList
|
||||
items={moduleLessons}
|
||||
onReorder={(active, over) =>
|
||||
reorder(lessons, setLessons, active, over)
|
||||
void handleLessonReorder(module.id, active, over)
|
||||
}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
onEdit={(id) => handleEdit("lesson", id)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,13 +9,17 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import { ResolvedImage } from "../../../components/media/ResolvedImage";
|
||||
import type { ParentContextPractice } from "../../../types/course.types";
|
||||
import type {
|
||||
ParentContextPractice,
|
||||
PracticePublishStatus,
|
||||
} from "../../../types/course.types";
|
||||
import {
|
||||
isPracticePublished,
|
||||
practicePublishStatus,
|
||||
} from "../../../lib/parentContextPractice";
|
||||
import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
|
||||
|
||||
type ModulePracticeCardProps = {
|
||||
practice: ParentContextPractice;
|
||||
|
|
@ -39,117 +43,152 @@ export function ModulePracticeCard({
|
|||
[practice.story_image],
|
||||
);
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStatus, setPendingStatus] =
|
||||
useState<PracticePublishStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbFailed(false);
|
||||
}, [thumbnailSrc]);
|
||||
|
||||
return (
|
||||
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
|
||||
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
|
||||
{thumbnailSrc && !thumbFailed ? (
|
||||
<ResolvedImage
|
||||
src={thumbnailSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setThumbFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
const requestStatusChange = (
|
||||
nextStatus: PracticePublishStatus,
|
||||
e?: React.MouseEvent,
|
||||
) => {
|
||||
e?.stopPropagation();
|
||||
if (statusUpdating) return;
|
||||
setPendingStatus(nextStatus);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-5 p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
|
||||
isPublished
|
||||
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
|
||||
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
|
||||
)}
|
||||
>
|
||||
const confirmStatusChange = () => {
|
||||
if (!pendingStatus) return;
|
||||
if (pendingStatus === "PUBLISHED") {
|
||||
onPublish?.();
|
||||
} else {
|
||||
onSaveAsDraft?.();
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
setPendingStatus(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
|
||||
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
|
||||
{thumbnailSrc && !thumbFailed ? (
|
||||
<ResolvedImage
|
||||
src={thumbnailSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
onError={() => setThumbFailed(true)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-5 p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
|
||||
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
|
||||
isPublished
|
||||
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
|
||||
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
|
||||
)}
|
||||
/>
|
||||
{statusLabel}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
|
||||
disabled={statusUpdating}
|
||||
aria-label={`Practice options: ${practice.title}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{statusUpdating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={statusUpdating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isPublished) {
|
||||
onSaveAsDraft?.();
|
||||
} else {
|
||||
onPublish?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPublished ? "Save as draft" : "Publish practice"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
/>
|
||||
{statusLabel}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
|
||||
disabled={statusUpdating}
|
||||
aria-label={`Practice options: ${practice.title}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{statusUpdating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={statusUpdating}
|
||||
onClick={(e) => {
|
||||
requestStatusChange(
|
||||
isPublished ? "DRAFT" : "PUBLISHED",
|
||||
e,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isPublished ? "Save as draft" : "Publish practice"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
|
||||
{practice.title}
|
||||
</h3>
|
||||
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
|
||||
{practice.title}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPublished || statusUpdating}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
|
||||
isPublished
|
||||
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isPublished) onPublish?.();
|
||||
}}
|
||||
>
|
||||
{statusUpdating
|
||||
? "Updating…"
|
||||
: isPublished
|
||||
? "Published"
|
||||
: "Publish"}
|
||||
</Button>
|
||||
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPublished || statusUpdating}
|
||||
className={cn(
|
||||
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
|
||||
isPublished
|
||||
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
|
||||
: "bg-brand-500 text-white hover:bg-brand-600",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isPublished) requestStatusChange("PUBLISHED", e);
|
||||
}}
|
||||
>
|
||||
{statusUpdating
|
||||
? "Updating…"
|
||||
: isPublished
|
||||
? "Published"
|
||||
: "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
<PublishStatusConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setConfirmOpen(open);
|
||||
if (!open) setPendingStatus(null);
|
||||
}}
|
||||
nextStatus={pendingStatus}
|
||||
contentLabel="practice"
|
||||
confirming={statusUpdating}
|
||||
onConfirm={confirmStatusChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,7 +33,12 @@ import {
|
|||
isDirectVideoFileUrl,
|
||||
} from "../../../lib/videoPreview";
|
||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
||||
import type { PracticePublishStatus } from "../../../types/course.types";
|
||||
import { PublishStatusConfirmDialog } from "./PublishStatusConfirmDialog";
|
||||
import type {
|
||||
ContentAccessTier,
|
||||
PracticePublishStatus,
|
||||
} from "../../../types/course.types";
|
||||
import { ContentAccessTierChip } from "./ContentAccessTierChip";
|
||||
|
||||
function resolvePublishBadge(
|
||||
publishStatus?: PracticePublishStatus | string | null,
|
||||
|
|
@ -90,6 +95,9 @@ interface VideoCardProps {
|
|||
/** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
|
||||
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
||||
publishStatusUpdating?: boolean;
|
||||
accessTier?: ContentAccessTier | string | null;
|
||||
onToggleAccessTier?: (nextTier: ContentAccessTier) => void;
|
||||
accessTierUpdating?: boolean;
|
||||
/** Shown under title on module lesson cards; reserved height keeps grid rows even. */
|
||||
description?: string | null;
|
||||
}
|
||||
|
|
@ -110,6 +118,9 @@ export function VideoCard({
|
|||
onViewPractices,
|
||||
onTogglePublishStatus,
|
||||
publishStatusUpdating = false,
|
||||
accessTier,
|
||||
onToggleAccessTier,
|
||||
accessTierUpdating = false,
|
||||
hoverModuleActions = false,
|
||||
description,
|
||||
}: VideoCardProps) {
|
||||
|
|
@ -118,6 +129,9 @@ export function VideoCard({
|
|||
number | null
|
||||
>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
|
||||
const [pendingPublishStatus, setPendingPublishStatus] =
|
||||
useState<PracticePublishStatus | null>(null);
|
||||
/** Iframe players ignore URL limits in many cases — unmount after real time. */
|
||||
const [iframeSessionDone, setIframeSessionDone] = useState(false);
|
||||
const [iframeSessionKey, setIframeSessionKey] = useState(0);
|
||||
|
|
@ -138,6 +152,23 @@ export function VideoCard({
|
|||
const previewLengthLabel = formatPreviewLength(
|
||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||
);
|
||||
const requestPublishStatusChange = (
|
||||
nextStatus: PracticePublishStatus,
|
||||
e?: React.MouseEvent,
|
||||
) => {
|
||||
e?.stopPropagation();
|
||||
if (publishStatusUpdating) return;
|
||||
setPendingPublishStatus(nextStatus);
|
||||
setPublishConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmPublishStatusChange = () => {
|
||||
if (!pendingPublishStatus || !onTogglePublishStatus) return;
|
||||
onTogglePublishStatus(pendingPublishStatus);
|
||||
setPublishConfirmOpen(false);
|
||||
setPendingPublishStatus(null);
|
||||
};
|
||||
|
||||
const publishBadge = resolvePublishBadge(
|
||||
publishStatus,
|
||||
status,
|
||||
|
|
@ -242,6 +273,7 @@ export function VideoCard({
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex h-full min-h-0 flex-col overflow-hidden rounded-[24px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg",
|
||||
|
|
@ -453,30 +485,39 @@ export function VideoCard({
|
|||
"justify-between",
|
||||
)}
|
||||
>
|
||||
{/* Publish status badge */}
|
||||
{publishBadge ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
|
||||
publishBadge.isPublished
|
||||
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
|
||||
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{publishBadge ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
|
||||
publishBadge.isPublished
|
||||
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
|
||||
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||
)}
|
||||
/>
|
||||
{publishBadge.label}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
|
||||
Lesson
|
||||
</div>
|
||||
)}
|
||||
{accessTier != null || onToggleAccessTier ? (
|
||||
<ContentAccessTierChip
|
||||
accessTier={accessTier}
|
||||
updating={accessTierUpdating}
|
||||
contentLabel="lesson"
|
||||
onToggle={onToggleAccessTier}
|
||||
/>
|
||||
{publishBadge.label}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
|
||||
Lesson
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{hoverModuleActions && onTogglePublishStatus ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -485,11 +526,11 @@ export function VideoCard({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
|
||||
disabled={publishStatusUpdating}
|
||||
disabled={publishStatusUpdating || accessTierUpdating}
|
||||
aria-label={`Lesson options: ${title}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{publishStatusUpdating ? (
|
||||
{publishStatusUpdating || accessTierUpdating ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
|
|
@ -498,11 +539,11 @@ export function VideoCard({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
disabled={publishStatusUpdating}
|
||||
disabled={publishStatusUpdating || accessTierUpdating}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePublishStatus(
|
||||
requestPublishStatusChange(
|
||||
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
|
||||
e,
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
|
@ -578,5 +619,17 @@ export function VideoCard({
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PublishStatusConfirmDialog
|
||||
open={publishConfirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPublishConfirmOpen(open);
|
||||
if (!open) setPendingPublishStatus(null);
|
||||
}}
|
||||
nextStatus={pendingPublishStatus}
|
||||
contentLabel="lesson"
|
||||
confirming={publishStatusUpdating}
|
||||
onConfirm={confirmPublishStatusChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
|||
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
||||
import type { TeamMember } from "../../types/team.types";
|
||||
import { toast } from "sonner";
|
||||
import { InviteTeamMemberDialog } from "../role-management/components/InviteTeamMemberDialog";
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
|
|
@ -96,9 +97,9 @@ export function TeamManagementPage() {
|
|||
const [confirmDialog, setConfirmDialog] = useState<{ id: number; name: string; newStatus: string } | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMembers = async () => {
|
||||
const fetchMembers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const batchSize = 100;
|
||||
|
|
@ -128,7 +129,8 @@ export function TeamManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
fetchMembers();
|
||||
useEffect(() => {
|
||||
void fetchMembers();
|
||||
}, []);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
|
|
@ -224,7 +226,7 @@ export function TeamManagementPage() {
|
|||
</div>
|
||||
<Button
|
||||
className="bg-brand-600 hover:bg-brand-500 text-white w-full sm:w-auto"
|
||||
onClick={() => navigate("/team/add")}
|
||||
onClick={() => setInviteOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Team Member
|
||||
|
|
@ -473,6 +475,12 @@ export function TeamManagementPage() {
|
|||
</div>
|
||||
|
||||
{/* Status Update Confirmation Modal */}
|
||||
<InviteTeamMemberDialog
|
||||
open={inviteOpen}
|
||||
onOpenChange={setInviteOpen}
|
||||
onInvited={() => void fetchMembers()}
|
||||
/>
|
||||
|
||||
{confirmDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-sm rounded-xl bg-white shadow-2xl">
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export interface LearningProgramListItem {
|
|||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
sort_order: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +113,8 @@ export interface ProgramCourseListItem {
|
|||
name: string
|
||||
description: string
|
||||
sort_order: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
created_at: string
|
||||
thumbnail?: string | null
|
||||
/** Some list endpoints may expose the image as `thumbnail_url` instead. */
|
||||
|
|
@ -159,6 +163,8 @@ export interface ExamPrepCatalogCourseItem {
|
|||
units_count?: number
|
||||
modules_count?: number
|
||||
lessons_count?: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
|
@ -212,6 +218,8 @@ export interface ExamPrepCatalogUnitItem {
|
|||
description?: string | null
|
||||
thumbnail?: string | null
|
||||
sort_order?: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
modules_count?: number
|
||||
lessons_count?: number
|
||||
videos_count?: number
|
||||
|
|
@ -271,6 +279,8 @@ export interface ExamPrepUnitModuleItem {
|
|||
thumbnail?: string | null
|
||||
icon?: string | null
|
||||
sort_order?: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
lessons_count?: number
|
||||
videos_count?: number
|
||||
practices_count?: number
|
||||
|
|
@ -331,6 +341,7 @@ export interface ExamPrepModuleLessonItem {
|
|||
description?: string | null
|
||||
sort_order?: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
/** Total length in seconds when the API provides it. */
|
||||
duration?: number | null
|
||||
duration_seconds?: number | null
|
||||
|
|
@ -457,6 +468,8 @@ export interface TopLevelCourseModuleItem {
|
|||
description: string
|
||||
icon?: string | null
|
||||
sort_order: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
|
@ -507,6 +520,7 @@ export interface TopLevelModuleLessonItem {
|
|||
description: string
|
||||
sort_order: number
|
||||
publish_status?: PracticePublishStatus | string | null
|
||||
access_tier?: ContentAccessTier | string | null
|
||||
has_practice?: boolean
|
||||
/** Total length in seconds when the API provides it. */
|
||||
duration?: number | null
|
||||
|
|
@ -559,6 +573,18 @@ export type PracticeParentKind = "COURSE" | "MODULE" | "LESSON"
|
|||
|
||||
export type PracticePublishStatus = "DRAFT" | "PUBLISHED"
|
||||
|
||||
export type ContentAccessTier = "FREE" | "PREMIUM"
|
||||
|
||||
/** PUT body when only toggling access_tier on a content resource. */
|
||||
export interface AccessTierOnlyRequest {
|
||||
access_tier: ContentAccessTier
|
||||
}
|
||||
|
||||
/** PUT body when only toggling draft/published on a content resource. */
|
||||
export interface PublishStatusOnlyRequest {
|
||||
publish_status: PracticePublishStatus
|
||||
}
|
||||
|
||||
/** POST /practices — create practice linked to a course, module, or lesson (Learn English). */
|
||||
export interface CreateParentLinkedPracticeRequest {
|
||||
parent_kind: PracticeParentKind
|
||||
|
|
@ -1639,6 +1665,11 @@ export interface ReorderItem {
|
|||
position: number
|
||||
}
|
||||
|
||||
/** Reorder endpoints: PUT with { ordered_ids: number[] } */
|
||||
export interface ReorderOrderedIdsRequest {
|
||||
ordered_ids: number[]
|
||||
}
|
||||
|
||||
// Ratings
|
||||
export interface Rating {
|
||||
id: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user