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