feat(content): admin UX for forms, practices, lessons, and content hub
Remove description fields from course, unit, and module create/edit dialogs. Add unit sort order on create, lesson publish status and sort order, video duration on lesson cards, and personas API integration for Learn English practice flows. Move Manage Question Types to the new content hub, add Reorder Content page with hierarchy drag-and-drop, shared practice review UI, module practice cards, and publish-practice controls on course listings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
38550f9519
commit
b8a73c73db
|
|
@ -106,6 +106,7 @@ import type {
|
||||||
UpdateParentLinkedPracticeResponse,
|
UpdateParentLinkedPracticeResponse,
|
||||||
PublishParentLinkedPracticeRequest,
|
PublishParentLinkedPracticeRequest,
|
||||||
UpdateTopLevelModuleLessonRequest,
|
UpdateTopLevelModuleLessonRequest,
|
||||||
|
PublishTopLevelModuleLessonRequest,
|
||||||
CreateTopLevelModuleLessonRequest,
|
CreateTopLevelModuleLessonRequest,
|
||||||
CreateTopLevelModuleLessonResponse,
|
CreateTopLevelModuleLessonResponse,
|
||||||
} from "../types/course.types"
|
} from "../types/course.types"
|
||||||
|
|
@ -647,6 +648,12 @@ export const updateTopLevelModuleLesson = (
|
||||||
data: UpdateTopLevelModuleLessonRequest,
|
data: UpdateTopLevelModuleLessonRequest,
|
||||||
) => http.put(`/lessons/${lessonId}`, data)
|
) => http.put(`/lessons/${lessonId}`, data)
|
||||||
|
|
||||||
|
/** PUT /lessons/:id — set publish_status only (draft or published). */
|
||||||
|
export const publishTopLevelModuleLesson = (
|
||||||
|
lessonId: number,
|
||||||
|
data: PublishTopLevelModuleLessonRequest,
|
||||||
|
) => 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}`)
|
||||||
|
|
|
||||||
6
src/api/personas.api.ts
Normal file
6
src/api/personas.api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
|
||||||
|
|
||||||
|
/** GET /personas — list personas (filter active client-side when needed). */
|
||||||
|
export const getPersonas = (params?: GetPersonasParams) =>
|
||||||
|
http.get<GetPersonasResponse>("/personas", { params })
|
||||||
|
|
@ -15,6 +15,7 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
|
||||||
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
import { AddVideoPage } from "../pages/content-management/AddVideoPage";
|
||||||
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
import { AddPracticePage } from "../pages/content-management/AddPracticePage";
|
||||||
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
import { NewContentPage } from "../pages/content-management/NewContentPage";
|
||||||
|
import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
|
||||||
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
|
||||||
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
|
||||||
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
|
||||||
|
|
@ -162,6 +163,7 @@ export function AppRoutes() {
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/new-content" element={<NewContentPage />} />
|
<Route path="/new-content" element={<NewContentPage />} />
|
||||||
|
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/courses"
|
path="/new-content/courses"
|
||||||
element={<ProgramTypeSelectionPage />}
|
element={<ProgramTypeSelectionPage />}
|
||||||
|
|
|
||||||
43
src/hooks/useActivePersonas.ts
Normal file
43
src/hooks/useActivePersonas.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { getPersonas } from "../api/personas.api"
|
||||||
|
import {
|
||||||
|
mapPersonaToCard,
|
||||||
|
unwrapPersonasList,
|
||||||
|
type PersonaCardModel,
|
||||||
|
} from "../lib/personaDisplay"
|
||||||
|
|
||||||
|
type UseActivePersonasOptions = {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivePersonas(options: UseActivePersonasOptions = {}) {
|
||||||
|
const { limit = 50, offset = 0 } = options
|
||||||
|
const [personas, setPersonas] = useState<PersonaCardModel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await getPersonas({ limit, offset })
|
||||||
|
const list = unwrapPersonasList(res).filter((p) => p.is_active)
|
||||||
|
setPersonas(list.map(mapPersonaToCard))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to load personas"
|
||||||
|
setError(msg)
|
||||||
|
setPersonas([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [limit, offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return { personas, loading, error, reload: load }
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,9 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
storyDescription: string
|
storyDescription: string
|
||||||
storyImage: string
|
storyImage: string
|
||||||
quickTips: string
|
quickTips: string
|
||||||
|
personaName?: string | null
|
||||||
|
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
|
||||||
|
personaId: number
|
||||||
questions: LearnEnglishDefinitionQuestionInput[]
|
questions: LearnEnglishDefinitionQuestionInput[]
|
||||||
definitions: QuestionTypeDefinition[]
|
definitions: QuestionTypeDefinition[]
|
||||||
}): Promise<{ questionSetId: number; practiceId: number }> {
|
}): Promise<{ questionSetId: number; practiceId: number }> {
|
||||||
|
|
@ -72,6 +75,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
)
|
)
|
||||||
if (err) throw new Error(err)
|
if (err) throw new Error(err)
|
||||||
|
|
||||||
|
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
|
||||||
|
throw new Error("persona_id is required. Select a persona before saving.")
|
||||||
|
}
|
||||||
|
|
||||||
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
||||||
|
|
||||||
const setRes = await createQuestionSet({
|
const setRes = await createQuestionSet({
|
||||||
|
|
@ -82,6 +89,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
owner_id: opts.parentId,
|
owner_id: opts.parentId,
|
||||||
shuffle_questions: opts.shuffleQuestions,
|
shuffle_questions: opts.shuffleQuestions,
|
||||||
status: opts.status,
|
status: opts.status,
|
||||||
|
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const setId = setRes.data?.data?.id
|
const setId = setRes.data?.data?.id
|
||||||
|
|
@ -122,6 +130,7 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
question_set_id: setId,
|
question_set_id: setId,
|
||||||
quick_tips: opts.quickTips.trim(),
|
quick_tips: opts.quickTips.trim(),
|
||||||
publish_status: opts.status,
|
publish_status: opts.status,
|
||||||
|
persona_id: opts.personaId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const practiceId = practiceRes.data?.data?.id
|
const practiceId = practiceRes.data?.data?.id
|
||||||
|
|
|
||||||
52
src/lib/personaDisplay.ts
Normal file
52
src/lib/personaDisplay.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type {
|
||||||
|
GetPersonasResponse,
|
||||||
|
PersonaListItem,
|
||||||
|
} from "../types/persona.types"
|
||||||
|
|
||||||
|
export type PersonaCardModel = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
|
||||||
|
const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default avatar when `profile_picture` is null: professional illustrated portrait
|
||||||
|
* (DiceBear personas), not casual cartoon avataaars.
|
||||||
|
*/
|
||||||
|
export function personaAvatarUrl(
|
||||||
|
profilePicture: string | null | undefined,
|
||||||
|
name: string,
|
||||||
|
personaId?: number | string,
|
||||||
|
): string {
|
||||||
|
const url = profilePicture?.trim()
|
||||||
|
if (url) return url
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
|
||||||
|
backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
|
||||||
|
radius: "50",
|
||||||
|
})
|
||||||
|
return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
|
||||||
|
return {
|
||||||
|
id: String(persona.id),
|
||||||
|
name: persona.name,
|
||||||
|
description: persona.description?.trim() ?? "",
|
||||||
|
avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapPersonasList(
|
||||||
|
res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
|
||||||
|
): PersonaListItem[] {
|
||||||
|
const body = res.data
|
||||||
|
if (!body) return []
|
||||||
|
const data = body.data ?? body.Data
|
||||||
|
const raw = data?.personas
|
||||||
|
return Array.isArray(raw) ? raw : []
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
|
||||||
return `${totalSeconds} seconds`;
|
return `${totalSeconds} seconds`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact label for thumbnails (e.g. `3:02`, `1:05:07`). */
|
||||||
|
export function formatVideoDurationLabel(totalSeconds: number): string {
|
||||||
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "";
|
||||||
|
const s = Math.round(totalSeconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) {
|
||||||
|
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTube: `end` = stop after this many seconds from the start of the video.
|
* YouTube: `end` = stop after this many seconds from the start of the video.
|
||||||
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
* Vimeo: time range in URL fragment (supported on many vimeo.com player links).
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Edit,
|
|
||||||
Rocket,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -19,6 +17,9 @@ import { Card } from "../../components/ui/card";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
|
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
|
||||||
|
import { AddNewPracticeReviewStep } from "./components/AddNewPracticeReviewStep";
|
||||||
|
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
||||||
|
import { useActivePersonas } from "../../hooks/useActivePersonas";
|
||||||
import {
|
import {
|
||||||
createQuestionSet,
|
createQuestionSet,
|
||||||
createQuestion,
|
createQuestion,
|
||||||
|
|
@ -34,12 +35,6 @@ type ResultStatus = "success" | "error";
|
||||||
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
|
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
|
||||||
|
|
||||||
interface Persona {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MCQOption {
|
interface MCQOption {
|
||||||
text: string;
|
text: string;
|
||||||
isCorrect: boolean;
|
isCorrect: boolean;
|
||||||
|
|
@ -65,49 +60,6 @@ interface Question {
|
||||||
dynamicFieldValues: Record<string, string>;
|
dynamicFieldValues: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERSONAS: Persona[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Dawit",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Mahlet",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Amanuel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "Bethel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
name: "Liya",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
name: "Aseffa",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "7",
|
|
||||||
name: "Hana",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "8",
|
|
||||||
name: "Nahom",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ number: 1, label: "Context" },
|
{ number: 1, label: "Context" },
|
||||||
{ number: 2, label: "Persona" },
|
{ number: 2, label: "Persona" },
|
||||||
|
|
@ -158,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
|
||||||
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeAdminRichTextHtml(input: string): string {
|
|
||||||
if (!input.trim()) return "";
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(input, "text/html");
|
|
||||||
const blockedTags = new Set([
|
|
||||||
"script",
|
|
||||||
"style",
|
|
||||||
"iframe",
|
|
||||||
"object",
|
|
||||||
"embed",
|
|
||||||
"link",
|
|
||||||
"meta",
|
|
||||||
]);
|
|
||||||
doc.body.querySelectorAll("*").forEach((el) => {
|
|
||||||
const tagName = el.tagName.toLowerCase();
|
|
||||||
if (blockedTags.has(tagName)) {
|
|
||||||
el.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const attrs = [...el.attributes];
|
|
||||||
attrs.forEach((attr) => {
|
|
||||||
const name = attr.name.toLowerCase();
|
|
||||||
const value = attr.value.trim().toLowerCase();
|
|
||||||
if (name.startsWith("on")) {
|
|
||||||
el.removeAttribute(attr.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(name === "href" || name === "src") &&
|
|
||||||
value.startsWith("javascript:")
|
|
||||||
) {
|
|
||||||
el.removeAttribute(attr.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return doc.body.innerHTML;
|
|
||||||
} catch {
|
|
||||||
return escapeHtml(input).replace(/\r?\n/g, "<br />");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDescriptionForPreview(raw: string): string {
|
|
||||||
if (!raw.trim()) return "";
|
|
||||||
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
|
|
||||||
if (hasHtml) return sanitizeAdminRichTextHtml(raw);
|
|
||||||
return escapeHtml(raw).replace(/\r?\n/g, "<br />");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyQuestion(id: string): Question {
|
function createEmptyQuestion(id: string): Question {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -281,6 +175,12 @@ export function AddNewPracticePage() {
|
||||||
|
|
||||||
// Step 2: Persona
|
// Step 2: Persona
|
||||||
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||||
|
const {
|
||||||
|
personas,
|
||||||
|
loading: personasLoading,
|
||||||
|
error: personasError,
|
||||||
|
reload: reloadPersonas,
|
||||||
|
} = useActivePersonas();
|
||||||
|
|
||||||
// Step 3: Questions
|
// Step 3: Questions
|
||||||
const [questions, setQuestions] = useState<Question[]>([
|
const [questions, setQuestions] = useState<Question[]>([
|
||||||
|
|
@ -373,11 +273,6 @@ export function AddNewPracticePage() {
|
||||||
return null;
|
return null;
|
||||||
}, [introVideoUrl]);
|
}, [introVideoUrl]);
|
||||||
|
|
||||||
const descriptionPreviewHtml = useMemo(
|
|
||||||
() => formatDescriptionForPreview(practiceDescription),
|
|
||||||
[practiceDescription],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addQuestion = () => {
|
const addQuestion = () => {
|
||||||
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
|
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
|
||||||
};
|
};
|
||||||
|
|
@ -398,7 +293,12 @@ export function AddNewPracticePage() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
const persona = PERSONAS.find((p) => p.id === selectedPersona);
|
if (!selectedPersona) {
|
||||||
|
toast.error("Select a persona before saving.");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persona = personas.find((p) => p.id === selectedPersona);
|
||||||
const setRes = await createQuestionSet({
|
const setRes = await createQuestionSet({
|
||||||
title: practiceTitle || "Untitled Practice",
|
title: practiceTitle || "Untitled Practice",
|
||||||
set_type: "PRACTICE",
|
set_type: "PRACTICE",
|
||||||
|
|
@ -899,66 +799,17 @@ export function AddNewPracticePage() {
|
||||||
practice.
|
practice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 sm:p-8 lg:p-10">
|
<div className="p-5 sm:p-8 lg:p-10">
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5">
|
<PersonaStep
|
||||||
{PERSONAS.map((persona) => (
|
personas={personas}
|
||||||
<button
|
loading={personasLoading}
|
||||||
key={persona.id}
|
error={personasError}
|
||||||
onClick={() => setSelectedPersona(persona.id)}
|
onRetry={() => void reloadPersonas()}
|
||||||
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${
|
selectedPersona={selectedPersona}
|
||||||
selectedPersona === persona.id
|
setSelectedPersona={setSelectedPersona}
|
||||||
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100"
|
nextStep={handleNext}
|
||||||
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm"
|
prevStep={handleBack}
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
{selectedPersona === persona.id && (
|
|
||||||
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
|
|
||||||
selectedPersona === persona.id
|
|
||||||
? "ring-brand-300 ring-offset-2"
|
|
||||||
: "ring-transparent group-hover:ring-grayScale-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={persona.avatar}
|
|
||||||
alt={persona.name}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-sm font-semibold transition-colors ${
|
|
||||||
selectedPersona === persona.id
|
|
||||||
? "text-brand-600"
|
|
||||||
: "text-grayScale-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{persona.name}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
|
|
||||||
onClick={handleNext}
|
|
||||||
>
|
|
||||||
{getNextButtonLabel()}
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1075,261 +926,26 @@ export function AddNewPracticePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="w-full space-y-6">
|
<AddNewPracticeReviewStep
|
||||||
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6">
|
practiceTitle={practiceTitle}
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl">
|
practiceDescription={practiceDescription}
|
||||||
Step 4: Review & publish
|
selectedProgram={selectedProgram}
|
||||||
</h2>
|
selectedCourse={selectedCourse}
|
||||||
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
|
moduleLabel={
|
||||||
Confirm context, persona, and questions before saving or
|
subModuleId ? `Module ${subModuleId}` : "Current module"
|
||||||
publishing.
|
}
|
||||||
</p>
|
selectedPersona={selectedPersona}
|
||||||
</div>
|
personas={personas}
|
||||||
|
introVideoPreview={introVideoPreview}
|
||||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8">
|
questions={questions}
|
||||||
{/* Basic Information Card */}
|
saving={saving}
|
||||||
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
|
saveError={saveError}
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
onEditContext={() => setCurrentStep(1)}
|
||||||
<h3 className="font-semibold text-grayScale-900">
|
onEditQuestions={() => setCurrentStep(3)}
|
||||||
Basic Information
|
onBack={handleBack}
|
||||||
</h3>
|
onSaveDraft={handleSaveAsDraft}
|
||||||
<button
|
onPublish={handlePublish}
|
||||||
onClick={() => setCurrentStep(1)}
|
/>
|
||||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
|
||||||
>
|
|
||||||
<Edit className="h-3.5 w-3.5" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-grayScale-100">
|
|
||||||
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
|
|
||||||
<span className="text-sm text-grayScale-500">Title</span>
|
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
|
||||||
{practiceTitle || "Untitled Practice"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-grayScale-50/50 px-6 py-4">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Description
|
|
||||||
</span>
|
|
||||||
{descriptionPreviewHtml ? (
|
|
||||||
<div
|
|
||||||
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: descriptionPreviewHtml,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="mt-2 text-sm text-grayScale-400">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between px-6 py-3.5">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Intro video URL
|
|
||||||
</span>
|
|
||||||
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
|
|
||||||
{introVideoUrl.trim() || "—"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{introVideoPreview ? (
|
|
||||||
<div className="bg-grayScale-50/50 px-6 py-4">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Intro video preview
|
|
||||||
</span>
|
|
||||||
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
|
|
||||||
{introVideoPreview.kind === "vimeo" ? (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
|
|
||||||
<iframe
|
|
||||||
src={introVideoPreview.url}
|
|
||||||
title="Intro video preview"
|
|
||||||
className="aspect-video w-full"
|
|
||||||
allow="autoplay; fullscreen; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<video
|
|
||||||
controls
|
|
||||||
src={introVideoPreview.url}
|
|
||||||
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Passing Score
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
|
||||||
{passingScore}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between px-6 py-3.5">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Time Limit
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
|
||||||
{timeLimitMinutes} minutes
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
|
|
||||||
<span className="text-sm text-grayScale-500">
|
|
||||||
Shuffle Questions
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-grayScale-900">
|
|
||||||
{shuffleQuestions ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between px-6 py-3.5">
|
|
||||||
<span className="text-sm text-grayScale-500">Persona</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedPersona && (
|
|
||||||
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
PERSONAS.find((p) => p.id === selectedPersona)
|
|
||||||
?.avatar
|
|
||||||
}
|
|
||||||
alt="Persona"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium text-brand-600">
|
|
||||||
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
|
|
||||||
"None selected"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Questions Review */}
|
|
||||||
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
|
|
||||||
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<h3 className="font-semibold text-grayScale-900">
|
|
||||||
Questions
|
|
||||||
</h3>
|
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
|
|
||||||
{questions.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentStep(3)}
|
|
||||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
|
||||||
>
|
|
||||||
<Edit className="h-3.5 w-3.5" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
|
|
||||||
{questions.map((question, index) => (
|
|
||||||
<div
|
|
||||||
key={question.id}
|
|
||||||
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 space-y-2.5">
|
|
||||||
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
|
|
||||||
{question.questionText}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
|
|
||||||
{question.questionType === "MCQ"
|
|
||||||
? "Multiple Choice"
|
|
||||||
: question.questionType === "TRUE_FALSE"
|
|
||||||
? "True/False"
|
|
||||||
: question.questionType === "AUDIO"
|
|
||||||
? "Audio"
|
|
||||||
: question.questionType === "DYNAMIC"
|
|
||||||
? "Dynamic"
|
|
||||||
: "Short Answer"}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
|
|
||||||
{question.difficultyLevel}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
|
|
||||||
{question.points} pt
|
|
||||||
{question.points !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{question.questionType === "MCQ" &&
|
|
||||||
question.options.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-1">
|
|
||||||
{question.options.map((opt, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
|
|
||||||
opt.isCorrect
|
|
||||||
? "bg-green-50 font-medium text-green-700"
|
|
||||||
: "text-grayScale-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.isCorrect && (
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
{opt.text || `Option ${i + 1}`}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{question.tips && (
|
|
||||||
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
|
|
||||||
💡 Tip: {question.tips}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{question.explanation && (
|
|
||||||
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
|
|
||||||
Explanation: {question.explanation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveError && (
|
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
||||||
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleSaveAsDraft}
|
|
||||||
disabled={saving}
|
|
||||||
className="sm:min-w-[140px]"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save as Draft"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
|
|
||||||
onClick={handlePublish}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
<Rocket className="mr-2 h-4 w-4" />
|
|
||||||
{saving ? "Publishing..." : "Publish Now"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Result */}
|
{/* Step 5: Result */}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,16 @@ import {
|
||||||
|
|
||||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
import { ContextStep } from "./components/practice-steps/ContextStep";
|
||||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||||
|
import { PersonaStep } from "./components/practice-steps/PersonaStep";
|
||||||
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
|
||||||
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
import { ReviewStep } from "./components/practice-steps/ReviewStep";
|
||||||
|
import {
|
||||||
|
personaFromId,
|
||||||
|
personaIdNumber,
|
||||||
|
} from "./components/practice-steps/constants";
|
||||||
|
import { useActivePersonas } from "../../hooks/useActivePersonas";
|
||||||
|
|
||||||
const STEP_LABELS = ["Practice", "Questions", "Review"] as const;
|
const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
|
||||||
|
|
||||||
export function AddPracticeFlow() {
|
export function AddPracticeFlow() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -48,6 +54,10 @@ export function AddPracticeFlow() {
|
||||||
|
|
||||||
const isModuleContext = backTo === "module";
|
const isModuleContext = backTo === "module";
|
||||||
const isCourseContext = backTo === "modules";
|
const isCourseContext = backTo === "modules";
|
||||||
|
const isLessonPractice = useMemo(() => {
|
||||||
|
const lid = lessonId ? Number(lessonId) : NaN;
|
||||||
|
return Number.isFinite(lid) && lid > 0;
|
||||||
|
}, [lessonId]);
|
||||||
|
|
||||||
const parentContext = useMemo((): {
|
const parentContext = useMemo((): {
|
||||||
kind: PracticeParentKind;
|
kind: PracticeParentKind;
|
||||||
|
|
@ -96,12 +106,19 @@ export function AddPracticeFlow() {
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
|
||||||
|
const {
|
||||||
|
personas,
|
||||||
|
loading: personasLoading,
|
||||||
|
error: personasError,
|
||||||
|
reload: reloadPersonas,
|
||||||
|
} = useActivePersonas();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
storyImageUrl: "",
|
storyImageUrl: "",
|
||||||
shuffleQuestions: false,
|
shuffleQuestions: false,
|
||||||
publishStatus: "DRAFT" as const,
|
|
||||||
tips: "",
|
tips: "",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
|
|
@ -176,12 +193,29 @@ export function AddPracticeFlow() {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.title.trim() || !formData.description.trim()) {
|
if (
|
||||||
|
!isLessonPractice &&
|
||||||
|
(!formData.title.trim() || !formData.description.trim())
|
||||||
|
) {
|
||||||
toast.error("Title and story description are required", {
|
toast.error("Title and story description are required", {
|
||||||
description: "Complete the first step before publishing.",
|
description: "Complete the first step before publishing.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!selectedPersona) {
|
||||||
|
toast.error("Select a persona", {
|
||||||
|
description: "Choose a character on the Persona step before publishing.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const personaId = personaIdNumber(selectedPersona);
|
||||||
|
if (!personaId) {
|
||||||
|
toast.error("Invalid persona", {
|
||||||
|
description: "Re-select a persona from the list and try again.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const persona = personaFromId(selectedPersona, personas);
|
||||||
const mappedQuestions = formData.questions
|
const mappedQuestions = formData.questions
|
||||||
.filter((q) => String(q.text ?? "").trim())
|
.filter((q) => String(q.text ?? "").trim())
|
||||||
.map((q) => ({
|
.map((q) => ({
|
||||||
|
|
@ -207,19 +241,33 @@ export function AddPracticeFlow() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lessonDefaultTitle =
|
||||||
|
lessonTitleDisplay?.trim() ||
|
||||||
|
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await executeLearnEnglishPracticeCreation({
|
await executeLearnEnglishPracticeCreation({
|
||||||
parentKind: parentContext.kind,
|
parentKind: parentContext.kind,
|
||||||
parentId: parentContext.id,
|
parentId: parentContext.id,
|
||||||
status,
|
status,
|
||||||
questionSetTitle: formData.title.trim() || "Practice set",
|
questionSetTitle: isLessonPractice
|
||||||
questionSetDescription: formData.description.trim() || null,
|
? lessonDefaultTitle
|
||||||
|
: formData.title.trim() || "Practice set",
|
||||||
|
questionSetDescription: isLessonPractice
|
||||||
|
? null
|
||||||
|
: formData.description.trim() || null,
|
||||||
shuffleQuestions: formData.shuffleQuestions,
|
shuffleQuestions: formData.shuffleQuestions,
|
||||||
practiceTitle: formData.title.trim() || "Untitled practice",
|
practiceTitle: isLessonPractice
|
||||||
storyDescription: formData.description.trim(),
|
? lessonDefaultTitle
|
||||||
storyImage: formData.storyImageUrl.trim(),
|
: formData.title.trim() || "Untitled practice",
|
||||||
|
storyDescription: isLessonPractice
|
||||||
|
? ""
|
||||||
|
: formData.description.trim(),
|
||||||
|
storyImage: isLessonPractice ? "" : formData.storyImageUrl.trim(),
|
||||||
quickTips: formData.tips.trim(),
|
quickTips: formData.tips.trim(),
|
||||||
|
personaName: persona?.name ?? null,
|
||||||
|
personaId,
|
||||||
questions: mappedQuestions,
|
questions: mappedQuestions,
|
||||||
definitions: typeDefinitions,
|
definitions: typeDefinitions,
|
||||||
});
|
});
|
||||||
|
|
@ -274,12 +322,12 @@ export function AddPracticeFlow() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
|
setSelectedPersona(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
title: "",
|
title: "",
|
||||||
description: "",
|
description: "",
|
||||||
storyImageUrl: "",
|
storyImageUrl: "",
|
||||||
shuffleQuestions: false,
|
shuffleQuestions: false,
|
||||||
publishStatus: "DRAFT" as const,
|
|
||||||
tips: "",
|
tips: "",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
|
|
@ -321,11 +369,25 @@ export function AddPracticeFlow() {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
nextStep={nextStep}
|
nextStep={nextStep}
|
||||||
navigate={navigate}
|
onCancel={() => navigate(backPath)}
|
||||||
level={level!}
|
isLessonPractice={isLessonPractice}
|
||||||
|
lessonTitle={lessonTitleDisplay}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
|
return (
|
||||||
|
<PersonaStep
|
||||||
|
personas={personas}
|
||||||
|
loading={personasLoading}
|
||||||
|
error={personasError}
|
||||||
|
onRetry={() => void reloadPersonas()}
|
||||||
|
selectedPersona={selectedPersona}
|
||||||
|
setSelectedPersona={setSelectedPersona}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
return (
|
return (
|
||||||
<QuestionsStep
|
<QuestionsStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
|
@ -337,12 +399,20 @@ export function AddPracticeFlow() {
|
||||||
definitionsError={definitionsError}
|
definitionsError={definitionsError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
selectedPersona={selectedPersona}
|
||||||
|
personas={personas}
|
||||||
|
isLessonPractice={isLessonPractice}
|
||||||
|
lessonTitle={lessonTitleDisplay}
|
||||||
|
programLabel={level ? `Program ${level}` : null}
|
||||||
|
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||||
|
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
|
onEditContext={() => setCurrentStep(1)}
|
||||||
|
onEditQuestions={() => setCurrentStep(3)}
|
||||||
parentSummary={parentSummary}
|
parentSummary={parentSummary}
|
||||||
typeDefinitions={typeDefinitions}
|
typeDefinitions={typeDefinitions}
|
||||||
canPublish={parentContext !== null}
|
canPublish={parentContext !== null}
|
||||||
|
|
@ -367,6 +437,19 @@ export function AddPracticeFlow() {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
|
return (
|
||||||
|
<PersonaStep
|
||||||
|
personas={personas}
|
||||||
|
loading={personasLoading}
|
||||||
|
error={personasError}
|
||||||
|
onRetry={() => void reloadPersonas()}
|
||||||
|
selectedPersona={selectedPersona}
|
||||||
|
setSelectedPersona={setSelectedPersona}
|
||||||
|
nextStep={nextStep}
|
||||||
|
prevStep={prevStep}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
return (
|
return (
|
||||||
<QuestionsStep
|
<QuestionsStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
|
@ -378,12 +461,20 @@ export function AddPracticeFlow() {
|
||||||
definitionsError={definitionsError}
|
definitionsError={definitionsError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<ReviewStep
|
<ReviewStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
selectedPersona={selectedPersona}
|
||||||
|
personas={personas}
|
||||||
|
isLessonPractice={isLessonPractice}
|
||||||
|
lessonTitle={lessonTitleDisplay}
|
||||||
|
programLabel={level ? `Program ${level}` : null}
|
||||||
|
courseLabel={courseId ? `Course ${courseId}` : null}
|
||||||
|
moduleLabel={moduleId ? `Module ${moduleId}` : null}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
|
onEditContext={() => setCurrentStep(1)}
|
||||||
|
onEditQuestions={() => setCurrentStep(3)}
|
||||||
parentSummary={parentSummary}
|
parentSummary={parentSummary}
|
||||||
typeDefinitions={typeDefinitions}
|
typeDefinitions={typeDefinitions}
|
||||||
canPublish={parentContext !== null}
|
canPublish={parentContext !== null}
|
||||||
|
|
@ -453,7 +544,7 @@ export function AddPracticeFlow() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mx-auto ${currentStep === 2 ? "max-w-6xl" : "max-w-4xl"}`}
|
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
|
||||||
>
|
>
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Stepper } from "../../components/ui/stepper";
|
import { Stepper } from "../../components/ui/stepper";
|
||||||
import { createModuleLesson } from "../../api/courses.api";
|
import { createModuleLesson } from "../../api/courses.api";
|
||||||
|
import type { PracticePublishStatus } from "../../types/course.types";
|
||||||
|
|
||||||
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
|
||||||
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
|
||||||
|
|
@ -17,7 +18,7 @@ const STEPS = [
|
||||||
|
|
||||||
export type AddLessonFormData = {
|
export type AddLessonFormData = {
|
||||||
title: string;
|
title: string;
|
||||||
order: string;
|
sortOrder: string;
|
||||||
description: string;
|
description: string;
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
|
@ -25,7 +26,7 @@ export type AddLessonFormData = {
|
||||||
|
|
||||||
const emptyForm = (): AddLessonFormData => ({
|
const emptyForm = (): AddLessonFormData => ({
|
||||||
title: "",
|
title: "",
|
||||||
order: "1",
|
sortOrder: "0",
|
||||||
description: "",
|
description: "",
|
||||||
videoUrl: "",
|
videoUrl: "",
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
|
|
@ -51,6 +52,8 @@ export function AddVideoFlow() {
|
||||||
}>();
|
}>();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
|
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
|
||||||
|
useState<PracticePublishStatus>("PUBLISHED");
|
||||||
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [formResetKey, setFormResetKey] = useState(0);
|
const [formResetKey, setFormResetKey] = useState(0);
|
||||||
|
|
@ -60,7 +63,7 @@ export function AddVideoFlow() {
|
||||||
|
|
||||||
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
||||||
const mid = Number(moduleId);
|
const mid = Number(moduleId);
|
||||||
if (!Number.isFinite(mid) || mid < 1) {
|
if (!Number.isFinite(mid) || mid < 1) {
|
||||||
toast.error("Invalid module");
|
toast.error("Invalid module");
|
||||||
|
|
@ -86,6 +89,16 @@ export function AddVideoFlow() {
|
||||||
toast.error("Description is required");
|
toast.error("Description is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortOrderRaw = formData.sortOrder.trim();
|
||||||
|
if (sortOrderRaw === "") {
|
||||||
|
toast.error("Sort order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sort_order = Number(sortOrderRaw);
|
||||||
|
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||||
|
toast.error("Sort order must be a whole number of 0 or greater");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
try {
|
try {
|
||||||
await createModuleLesson(mid, {
|
await createModuleLesson(mid, {
|
||||||
|
|
@ -93,8 +106,15 @@ export function AddVideoFlow() {
|
||||||
video_url: videoUrl,
|
video_url: videoUrl,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
description,
|
description,
|
||||||
|
sort_order,
|
||||||
|
publish_status: publishStatus,
|
||||||
});
|
});
|
||||||
toast.success("Lesson created");
|
setLastCreatedPublishStatus(publishStatus);
|
||||||
|
toast.success(
|
||||||
|
publishStatus === "DRAFT"
|
||||||
|
? "Lesson saved as draft"
|
||||||
|
: "Lesson published",
|
||||||
|
);
|
||||||
setIsPublished(true);
|
setIsPublished(true);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
@ -123,10 +143,14 @@ export function AddVideoFlow() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
|
||||||
Lesson created successfully
|
{lastCreatedPublishStatus === "DRAFT"
|
||||||
|
? "Lesson saved as draft"
|
||||||
|
: "Lesson published successfully"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
|
||||||
Your lesson is now available in this module.
|
{lastCreatedPublishStatus === "DRAFT"
|
||||||
|
? "You can finish editing and publish it later from the module."
|
||||||
|
: "Your lesson is now available in this module."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
<div className="flex flex-col gap-4 w-full max-w-[400px]">
|
||||||
|
|
@ -140,6 +164,7 @@ export function AddVideoFlow() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFormData(emptyForm());
|
setFormData(emptyForm());
|
||||||
setFormResetKey((k) => k + 1);
|
setFormResetKey((k) => k + 1);
|
||||||
|
setLastCreatedPublishStatus("PUBLISHED");
|
||||||
setIsPublished(false);
|
setIsPublished(false);
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
}}
|
}}
|
||||||
|
|
@ -205,7 +230,7 @@ export function AddVideoFlow() {
|
||||||
<ReviewPublishStep
|
<ReviewPublishStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
prevStep={prevStep}
|
prevStep={prevStep}
|
||||||
onPublish={() => void handlePublish()}
|
onCreateLesson={(status) => void handleCreateLesson(status)}
|
||||||
publishing={publishing}
|
publishing={publishing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
import alertSrc from "../../assets/Alert.svg";
|
||||||
|
|
@ -146,7 +145,6 @@ export function CourseDetailPage() {
|
||||||
const [editingModule, setEditingModule] =
|
const [editingModule, setEditingModule] =
|
||||||
useState<TopLevelCourseModuleItem | null>(null);
|
useState<TopLevelCourseModuleItem | null>(null);
|
||||||
const [editModuleName, setEditModuleName] = useState("");
|
const [editModuleName, setEditModuleName] = useState("");
|
||||||
const [editModuleDescription, setEditModuleDescription] = useState("");
|
|
||||||
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
|
const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
|
||||||
const [editModuleIcon, setEditModuleIcon] = useState("");
|
const [editModuleIcon, setEditModuleIcon] = useState("");
|
||||||
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
|
||||||
|
|
@ -160,7 +158,6 @@ export function CourseDetailPage() {
|
||||||
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
const openEditModule = (module: TopLevelCourseModuleItem) => {
|
||||||
setEditingModule(module);
|
setEditingModule(module);
|
||||||
setEditModuleName(module.name ?? "");
|
setEditModuleName(module.name ?? "");
|
||||||
setEditModuleDescription(module.description ?? "");
|
|
||||||
setEditModuleSortOrder(String(module.sort_order ?? 0));
|
setEditModuleSortOrder(String(module.sort_order ?? 0));
|
||||||
setEditModuleIcon(module.icon?.trim() ?? "");
|
setEditModuleIcon(module.icon?.trim() ?? "");
|
||||||
setEditModuleIconUploadBusy(false);
|
setEditModuleIconUploadBusy(false);
|
||||||
|
|
@ -284,7 +281,7 @@ export function CourseDetailPage() {
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourseModule(editingModule.id, {
|
await updateTopLevelCourseModule(editingModule.id, {
|
||||||
name,
|
name,
|
||||||
description: editModuleDescription.trim(),
|
description: editingModule.description?.trim() ?? "",
|
||||||
icon: editModuleIcon.trim(),
|
icon: editModuleIcon.trim(),
|
||||||
sort_order,
|
sort_order,
|
||||||
});
|
});
|
||||||
|
|
@ -430,8 +427,7 @@ export function CourseDetailPage() {
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||||
<DialogTitle>Edit module</DialogTitle>
|
<DialogTitle>Edit module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, sort order, and icon (upload or URL).
|
Update name, sort order, and icon (upload or URL). Saved with{" "}
|
||||||
Saved with{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
PUT /modules/:id
|
PUT /modules/:id
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -452,19 +448,6 @@ export function CourseDetailPage() {
|
||||||
disabled={savingModuleEdit}
|
disabled={savingModuleEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editModuleDescription}
|
|
||||||
onChange={(e) => setEditModuleDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y rounded-xl"
|
|
||||||
placeholder="Optional short description."
|
|
||||||
disabled={savingModuleEdit || editModuleIconUploadBusy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="edit-module-sort-order"
|
htmlFor="edit-module-sort-order"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -45,7 +44,7 @@ export function CourseManagementPage() {
|
||||||
const catalogCourseId = Number(courseId);
|
const catalogCourseId = Number(courseId);
|
||||||
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
const [addUnitOpen, setAddUnitOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
|
||||||
|
|
@ -66,7 +65,6 @@ export function CourseManagementPage() {
|
||||||
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("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
@ -152,7 +150,7 @@ export function CourseManagementPage() {
|
||||||
|
|
||||||
const clearCreateUnitForm = () => {
|
const clearCreateUnitForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
setCreateSortOrder("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
createThumbnailFileInputRef.current.value = "";
|
createThumbnailFileInputRef.current.value = "";
|
||||||
|
|
@ -202,13 +200,24 @@ export function CourseManagementPage() {
|
||||||
toast.error("Unit name is required");
|
toast.error("Unit name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortOrderRaw = createSortOrder.trim();
|
||||||
|
if (!sortOrderRaw) {
|
||||||
|
toast.error("Sort order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sort_order = Number(sortOrderRaw);
|
||||||
|
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||||
|
toast.error("Sort order must be a whole number of 0 or greater");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
|
||||||
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
const response = await createExamPrepCatalogUnit(catalogCourseId, {
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim() || null,
|
description: null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
void response;
|
void response;
|
||||||
await loadUnits();
|
await loadUnits();
|
||||||
|
|
@ -271,18 +280,16 @@ export function CourseManagementPage() {
|
||||||
const openEditUnit = (unit: (typeof units)[number]) => {
|
const openEditUnit = (unit: (typeof units)[number]) => {
|
||||||
setEditingUnitId(unit.id);
|
setEditingUnitId(unit.id);
|
||||||
setEditName(unit.name ?? "");
|
setEditName(unit.name ?? "");
|
||||||
setEditDescription(unit.description ?? "");
|
|
||||||
setEditThumbnail(unit.thumbnail ?? "");
|
setEditThumbnail(unit.thumbnail ?? "");
|
||||||
setEditSortOrder(String(unit.sortOrder ?? 1));
|
setEditSortOrder(String(unit.sortOrder ?? 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeEditUnit = () => {
|
const closeEditUnit = () => {
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
if (savingEdit || uploadingEditThumbnail) return;
|
||||||
setEditingUnitId(null);
|
setEditingUnitId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditSortOrder("1");
|
setEditSortOrder("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditUnitThumbnailFile = async (
|
const handleEditUnitThumbnailFile = async (
|
||||||
|
|
@ -320,20 +327,30 @@ export function CourseManagementPage() {
|
||||||
toast.error("Unit name is required");
|
toast.error("Unit name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortOrderNum = Number(editSortOrder);
|
const sortOrderRaw = editSortOrder.trim();
|
||||||
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) {
|
if (!sortOrderRaw) {
|
||||||
toast.error("Sort order must be a valid number");
|
toast.error("Sort order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sort_order = Number(sortOrderRaw);
|
||||||
|
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||||
|
toast.error("Sort order must be a whole number of 0 or greater");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
|
const existing = units.find((u) => u.id === editingUnitId);
|
||||||
|
const preservedDescription =
|
||||||
|
existing?.description && existing.description !== "—"
|
||||||
|
? existing.description
|
||||||
|
: null;
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
||||||
await updateExamPrepCatalogUnit(editingUnitId, {
|
await updateExamPrepCatalogUnit(editingUnitId, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim() || null,
|
description: preservedDescription,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
sort_order: sortOrderNum,
|
sort_order,
|
||||||
});
|
});
|
||||||
await loadUnits();
|
await loadUnits();
|
||||||
toast.success("Unit updated");
|
toast.success("Unit updated");
|
||||||
|
|
@ -425,18 +442,29 @@ export function CourseManagementPage() {
|
||||||
disabled={creating || uploadingThumbnail}
|
disabled={creating || uploadingThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label
|
||||||
Description
|
htmlFor="create-unit-sort-order"
|
||||||
|
className="text-[15px] text-grayScale-800"
|
||||||
|
>
|
||||||
|
Sort Order
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Input
|
||||||
value={createDescription}
|
id="create-unit-sort-order"
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
type="number"
|
||||||
placeholder="Short unit description"
|
min={0}
|
||||||
rows={4}
|
step={1}
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
inputMode="numeric"
|
||||||
|
value={createSortOrder}
|
||||||
|
onChange={(e) => setCreateSortOrder(e.target.value)}
|
||||||
|
placeholder="e.g. 0"
|
||||||
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={creating || uploadingThumbnail}
|
disabled={creating || uploadingThumbnail}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when units are listed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -690,25 +718,27 @@ export function CourseManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
<label
|
||||||
<Textarea
|
htmlFor="edit-unit-sort-order"
|
||||||
value={editDescription}
|
className="text-[15px] text-grayScale-800"
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
>
|
||||||
rows={4}
|
Sort Order
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
</label>
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
|
||||||
<Input
|
<Input
|
||||||
|
id="edit-unit-sort-order"
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
value={editSortOrder}
|
value={editSortOrder}
|
||||||
onChange={(e) => setEditSortOrder(e.target.value)}
|
onChange={(e) => setEditSortOrder(e.target.value)}
|
||||||
className="h-12 border-grayScale-400 rounded-[8px] px-4"
|
placeholder="e.g. 0"
|
||||||
|
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Lower numbers appear first when units are listed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
getExamPrepModuleLessons,
|
getExamPrepModuleLessons,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
|
||||||
|
import type { PracticePublishStatus } from "../../types/course.types";
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
const MOCK_PRACTICES = [
|
||||||
{
|
{
|
||||||
|
|
@ -64,6 +65,7 @@ export function CourseModuleDetailPage() {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
gradient: string;
|
gradient: string;
|
||||||
|
durationSeconds: number | null;
|
||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
const [createLessonOpen, setCreateLessonOpen] = useState(false);
|
||||||
|
|
@ -129,20 +131,28 @@ export function CourseModuleDetailPage() {
|
||||||
const rows = response.data?.data?.lessons;
|
const rows = response.data?.data?.lessons;
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
setLessons(
|
setLessons(
|
||||||
list.map((row, index) => ({
|
list.map((row, index) => {
|
||||||
|
const raw = row.duration_seconds ?? row.duration ?? null;
|
||||||
|
const n =
|
||||||
|
raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
|
||||||
|
const durationSeconds =
|
||||||
|
Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
title: row.title?.trim() || `Lesson ${row.id}`,
|
title: row.title?.trim() || `Lesson ${row.id}`,
|
||||||
videoUrl: row.video_url?.trim() || "",
|
videoUrl: row.video_url?.trim() || "",
|
||||||
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),
|
||||||
|
durationSeconds,
|
||||||
gradient:
|
gradient:
|
||||||
index % 3 === 1
|
index % 3 === 1
|
||||||
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)"
|
||||||
: index % 3 === 2
|
: index % 3 === 2
|
||||||
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)"
|
||||||
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)",
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
@ -252,7 +262,7 @@ export function CourseModuleDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateLesson = async () => {
|
const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
|
||||||
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
|
||||||
toast.error("Invalid module");
|
toast.error("Invalid module");
|
||||||
return;
|
return;
|
||||||
|
|
@ -276,9 +286,14 @@ export function CourseModuleDetailPage() {
|
||||||
video_url: videoUrl,
|
video_url: videoUrl,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
description: createDescription.trim() || null,
|
description: createDescription.trim() || null,
|
||||||
|
publish_status: publishStatus,
|
||||||
});
|
});
|
||||||
await loadLessons();
|
await loadLessons();
|
||||||
toast.success("Lesson created");
|
toast.success(
|
||||||
|
publishStatus === "DRAFT"
|
||||||
|
? "Lesson saved as draft"
|
||||||
|
: "Lesson created",
|
||||||
|
);
|
||||||
clearCreateLessonForm();
|
clearCreateLessonForm();
|
||||||
setCreateLessonOpen(false);
|
setCreateLessonOpen(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|
@ -641,7 +656,7 @@ export function CourseModuleDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3">
|
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -653,13 +668,22 @@ export function CourseModuleDetailPage() {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
|
||||||
|
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
||||||
|
onClick={() => void handleCreateLesson("DRAFT")}
|
||||||
|
>
|
||||||
|
{creatingLesson ? "Saving…" : "Save as draft"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
|
||||||
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
|
||||||
onClick={() => void handleCreateLesson()}
|
onClick={() => void handleCreateLesson("PUBLISHED")}
|
||||||
>
|
>
|
||||||
{creatingLesson ? "Creating..." : "Create Lesson"}
|
{creatingLesson ? "Creating..." : "Publish lesson"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -716,6 +740,7 @@ export function CourseModuleDetailPage() {
|
||||||
thumbnailUrl={lesson.thumbnail}
|
thumbnailUrl={lesson.thumbnail}
|
||||||
videoUrl={lesson.videoUrl}
|
videoUrl={lesson.videoUrl}
|
||||||
thumbnailGradient={lesson.gradient}
|
thumbnailGradient={lesson.gradient}
|
||||||
|
durationSeconds={lesson.durationSeconds}
|
||||||
hoverModuleActions
|
hoverModuleActions
|
||||||
onEdit={() => openEditLesson(lesson)}
|
onEdit={() => openEditLesson(lesson)}
|
||||||
onDelete={() => setDeletingLessonId(lesson.id)}
|
onDelete={() => setDeletingLessonId(lesson.id)}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
|
||||||
import {
|
import {
|
||||||
createModule,
|
createModule,
|
||||||
deleteModule,
|
deleteModule,
|
||||||
|
|
@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
const [createModuleTitle, setCreateModuleTitle] = useState("")
|
const [createModuleTitle, setCreateModuleTitle] = useState("")
|
||||||
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
|
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
|
||||||
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
|
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
|
||||||
const [createModuleDescription, setCreateModuleDescription] = useState("")
|
|
||||||
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
|
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
|
||||||
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
|
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
|
||||||
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
|
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
|
||||||
|
|
@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
const [editModuleSaving, setEditModuleSaving] = useState(false)
|
const [editModuleSaving, setEditModuleSaving] = useState(false)
|
||||||
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
|
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
|
||||||
const [editModuleTitle, setEditModuleTitle] = useState("")
|
const [editModuleTitle, setEditModuleTitle] = useState("")
|
||||||
const [editModuleDescription, setEditModuleDescription] = useState("")
|
|
||||||
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
|
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
|
||||||
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
|
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
|
||||||
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
|
const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
|
||||||
|
|
@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
setCreateModuleUseDefaultNaming(false)
|
setCreateModuleUseDefaultNaming(false)
|
||||||
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
|
setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
|
||||||
setCreateModuleTitle("")
|
setCreateModuleTitle("")
|
||||||
setCreateModuleDescription("")
|
|
||||||
setCreateModuleIconSource("url")
|
setCreateModuleIconSource("url")
|
||||||
setCreateModuleIconUrl("")
|
setCreateModuleIconUrl("")
|
||||||
setCreateModuleIconFile(null)
|
setCreateModuleIconFile(null)
|
||||||
|
|
@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
await createModule({
|
await createModule({
|
||||||
level_id: createModuleLevelId,
|
level_id: createModuleLevelId,
|
||||||
title,
|
title,
|
||||||
description: createModuleDescription.trim() || undefined,
|
|
||||||
icon_url: uploadedIconUrl,
|
icon_url: uploadedIconUrl,
|
||||||
display_order: createModuleDisplayOrder,
|
display_order: createModuleDisplayOrder,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
|
@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
levelKey,
|
levelKey,
|
||||||
})
|
})
|
||||||
setEditModuleTitle(module.title)
|
setEditModuleTitle(module.title)
|
||||||
setEditModuleDescription("")
|
|
||||||
setEditModuleDisplayOrder(moduleDisplayOrder)
|
setEditModuleDisplayOrder(moduleDisplayOrder)
|
||||||
setEditModuleIconSource("url")
|
setEditModuleIconSource("url")
|
||||||
setEditModuleIconUrl(existingIconUrl)
|
setEditModuleIconUrl(existingIconUrl)
|
||||||
|
|
@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
|
|
||||||
await updateModule(editModuleTarget.moduleId, {
|
await updateModule(editModuleTarget.moduleId, {
|
||||||
title,
|
title,
|
||||||
description: editModuleDescription.trim() || undefined,
|
|
||||||
icon_url: uploadedIconUrl,
|
icon_url: uploadedIconUrl,
|
||||||
display_order: editModuleDisplayOrder,
|
display_order: editModuleDisplayOrder,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
|
@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description (optional)</label>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={createModuleDescription}
|
|
||||||
onChange={(event) => setCreateModuleDescription(event.target.value)}
|
|
||||||
placeholder="Optional description"
|
|
||||||
disabled={createModuleSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
|
||||||
<div className="mb-2 grid grid-cols-2 gap-2">
|
<div className="mb-2 grid grid-cols-2 gap-2">
|
||||||
|
|
@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
value={editModuleDescription}
|
|
||||||
onChange={(event) => setEditModuleDescription(event.target.value)}
|
|
||||||
placeholder="New description"
|
|
||||||
disabled={editModuleSaving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
|
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Video,
|
|
||||||
Calendar,
|
|
||||||
Mic,
|
|
||||||
Layers,
|
|
||||||
Edit2,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
deleteTopLevelModuleLesson,
|
deleteTopLevelModuleLesson,
|
||||||
getModuleLessons,
|
getModuleLessons,
|
||||||
|
getPracticesByParentModule,
|
||||||
getTopLevelCourseModules,
|
getTopLevelCourseModules,
|
||||||
|
publishParentLinkedPractice,
|
||||||
|
publishTopLevelModuleLesson,
|
||||||
|
updateParentLinkedPractice,
|
||||||
updateTopLevelModuleLesson,
|
updateTopLevelModuleLesson,
|
||||||
} from "../../api/courses.api";
|
} from "../../api/courses.api";
|
||||||
import type { TopLevelModuleLessonItem } from "../../types/course.types";
|
import type {
|
||||||
|
ParentContextPractice,
|
||||||
|
PracticePublishStatus,
|
||||||
|
TopLevelModuleLessonItem,
|
||||||
|
} from "../../types/course.types";
|
||||||
|
import {
|
||||||
|
isPracticeDraft,
|
||||||
|
isPracticePublished,
|
||||||
|
unwrapPracticesList,
|
||||||
|
} from "../../lib/parentContextPractice";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -32,6 +36,7 @@ import { Textarea } from "../../components/ui/textarea";
|
||||||
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
|
||||||
|
import { ModulePracticeCard } from "./components/ModulePracticeCard";
|
||||||
import { VideoCard } from "./components/VideoCard";
|
import { VideoCard } from "./components/VideoCard";
|
||||||
|
|
||||||
const LESSON_THUMB_GRADIENTS = [
|
const LESSON_THUMB_GRADIENTS = [
|
||||||
|
|
@ -41,37 +46,6 @@ const LESSON_THUMB_GRADIENTS = [
|
||||||
"from-[#FCE7F3] to-[#F9A8D4]",
|
"from-[#FCE7F3] to-[#F9A8D4]",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const MOCK_PRACTICES = [
|
|
||||||
{
|
|
||||||
id: "p1",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p2",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p3",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "p4",
|
|
||||||
title: "Describe a Photo",
|
|
||||||
level: "IELTS",
|
|
||||||
variations: 12,
|
|
||||||
status: "Draft",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type ModuleDetailState = {
|
type ModuleDetailState = {
|
||||||
moduleName?: string;
|
moduleName?: string;
|
||||||
moduleDescription?: string;
|
moduleDescription?: string;
|
||||||
|
|
@ -87,13 +61,14 @@ 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("Draft");
|
const [activeFilter, setActiveFilter] = useState("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);
|
||||||
const [editingLesson, setEditingLesson] =
|
const [editingLesson, setEditingLesson] =
|
||||||
useState<TopLevelModuleLessonItem | null>(null);
|
useState<TopLevelModuleLessonItem | null>(null);
|
||||||
const [editLessonTitle, setEditLessonTitle] = useState("");
|
const [editLessonTitle, setEditLessonTitle] = useState("");
|
||||||
|
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
|
||||||
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
|
||||||
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
|
||||||
const [editLessonDescription, setEditLessonDescription] = useState("");
|
const [editLessonDescription, setEditLessonDescription] = useState("");
|
||||||
|
|
@ -104,7 +79,17 @@ export function ModuleDetailPage() {
|
||||||
const [deletingLesson, setDeletingLesson] =
|
const [deletingLesson, setDeletingLesson] =
|
||||||
useState<TopLevelModuleLessonItem | null>(null);
|
useState<TopLevelModuleLessonItem | null>(null);
|
||||||
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
|
||||||
const [practices] = useState(MOCK_PRACTICES);
|
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
|
||||||
|
const [practicesLoading, setPracticesLoading] = useState(false);
|
||||||
|
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
|
||||||
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
const [loadedModuleDescription, setLoadedModuleDescription] = useState<
|
||||||
string | null
|
string | null
|
||||||
|
|
@ -233,9 +218,96 @@ export function ModuleDetailPage() {
|
||||||
void loadModuleLessons({ showPageLoading: true });
|
void loadModuleLessons({ showPageLoading: true });
|
||||||
}, [loadModuleLessons]);
|
}, [loadModuleLessons]);
|
||||||
|
|
||||||
|
const loadModulePractices = useCallback(async () => {
|
||||||
|
const mid = Number(moduleId);
|
||||||
|
if (!Number.isFinite(mid) || mid < 1) {
|
||||||
|
setPractices([]);
|
||||||
|
setPracticesLoadError(null);
|
||||||
|
setPracticesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPracticesLoading(true);
|
||||||
|
setPracticesLoadError(null);
|
||||||
|
try {
|
||||||
|
const res = await getPracticesByParentModule(mid, {
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
setPractices(unwrapPracticesList(res));
|
||||||
|
} catch {
|
||||||
|
setPractices([]);
|
||||||
|
setPracticesLoadError("Failed to load practices. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setPracticesLoading(false);
|
||||||
|
}
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "practice") return;
|
||||||
|
void loadModulePractices();
|
||||||
|
}, [activeTab, loadModulePractices]);
|
||||||
|
|
||||||
|
const filteredPractices = useMemo(() => {
|
||||||
|
if (activeFilter === "Published") {
|
||||||
|
return practices.filter(isPracticePublished);
|
||||||
|
}
|
||||||
|
if (activeFilter === "Draft") {
|
||||||
|
return practices.filter(isPracticeDraft);
|
||||||
|
}
|
||||||
|
if (activeFilter === "Archived") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return practices;
|
||||||
|
}, [practices, activeFilter]);
|
||||||
|
|
||||||
|
const 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) => {
|
||||||
|
setPublishStatusPracticeId(practiceId);
|
||||||
|
try {
|
||||||
|
await updateParentLinkedPractice(practiceId, {
|
||||||
|
publish_status: "DRAFT",
|
||||||
|
});
|
||||||
|
setPractices((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.success("Practice saved as draft");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e);
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to save practice as draft";
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setPublishStatusPracticeId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
|
||||||
setEditingLesson(lesson);
|
setEditingLesson(lesson);
|
||||||
setEditLessonTitle(lesson.title ?? "");
|
setEditLessonTitle(lesson.title ?? "");
|
||||||
|
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
|
||||||
setEditLessonVideoUrl(lesson.video_url ?? "");
|
setEditLessonVideoUrl(lesson.video_url ?? "");
|
||||||
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
setEditLessonThumbnail(lesson.thumbnail ?? "");
|
||||||
setEditLessonDescription(lesson.description ?? "");
|
setEditLessonDescription(lesson.description ?? "");
|
||||||
|
|
@ -253,6 +325,16 @@ export function ModuleDetailPage() {
|
||||||
toast.error("Title is required");
|
toast.error("Title is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortOrderRaw = editLessonSortOrder.trim();
|
||||||
|
if (sortOrderRaw === "") {
|
||||||
|
toast.error("Sort order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sort_order = Number(sortOrderRaw);
|
||||||
|
if (!Number.isInteger(sort_order) || sort_order < 0) {
|
||||||
|
toast.error("Sort order must be a whole number of 0 or greater");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSavingLessonEdit(true);
|
setSavingLessonEdit(true);
|
||||||
try {
|
try {
|
||||||
await updateTopLevelModuleLesson(editingLesson.id, {
|
await updateTopLevelModuleLesson(editingLesson.id, {
|
||||||
|
|
@ -260,6 +342,7 @@ export function ModuleDetailPage() {
|
||||||
video_url: editLessonVideoUrl.trim(),
|
video_url: editLessonVideoUrl.trim(),
|
||||||
thumbnail: editLessonThumbnail.trim(),
|
thumbnail: editLessonThumbnail.trim(),
|
||||||
description: editLessonDescription.trim(),
|
description: editLessonDescription.trim(),
|
||||||
|
sort_order,
|
||||||
});
|
});
|
||||||
toast.success("Lesson updated");
|
toast.success("Lesson updated");
|
||||||
setEditingLesson(null);
|
setEditingLesson(null);
|
||||||
|
|
@ -275,6 +358,39 @@ export function ModuleDetailPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleLessonPublishStatus = async (
|
||||||
|
lessonId: number,
|
||||||
|
nextStatus: PracticePublishStatus,
|
||||||
|
) => {
|
||||||
|
setPublishStatusLessonId(lessonId);
|
||||||
|
try {
|
||||||
|
await publishTopLevelModuleLesson(lessonId, {
|
||||||
|
publish_status: nextStatus,
|
||||||
|
});
|
||||||
|
setLessons((prev) =>
|
||||||
|
prev.map((l) =>
|
||||||
|
l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
nextStatus === "PUBLISHED"
|
||||||
|
? "Lesson published"
|
||||||
|
: "Lesson saved as draft",
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e);
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
(nextStatus === "PUBLISHED"
|
||||||
|
? "Failed to publish lesson"
|
||||||
|
: "Failed to save lesson as draft");
|
||||||
|
toast.error(msg);
|
||||||
|
} finally {
|
||||||
|
setPublishStatusLessonId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirmDeleteLesson = async () => {
|
const handleConfirmDeleteLesson = async () => {
|
||||||
if (!deletingLesson) return;
|
if (!deletingLesson) return;
|
||||||
setDeletingLessonInFlight(true);
|
setDeletingLessonInFlight(true);
|
||||||
|
|
@ -393,9 +509,17 @@ export function ModuleDetailPage() {
|
||||||
id={lesson.id}
|
id={lesson.id}
|
||||||
title={lesson.title}
|
title={lesson.title}
|
||||||
videoUrl={lesson.video_url}
|
videoUrl={lesson.video_url}
|
||||||
|
publishStatus={lesson.publish_status}
|
||||||
hoverModuleActions
|
hoverModuleActions
|
||||||
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
|
||||||
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
|
||||||
|
durationSeconds={(() => {
|
||||||
|
const raw =
|
||||||
|
lesson.duration_seconds ?? lesson.duration ?? null;
|
||||||
|
if (raw == null) return null;
|
||||||
|
const n = typeof raw === "number" ? raw : Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
})()}
|
||||||
onEdit={() => openEditLesson(lesson)}
|
onEdit={() => openEditLesson(lesson)}
|
||||||
onDelete={() => setDeletingLesson(lesson)}
|
onDelete={() => setDeletingLesson(lesson)}
|
||||||
description={lesson.description}
|
description={lesson.description}
|
||||||
|
|
@ -409,6 +533,10 @@ export function ModuleDetailPage() {
|
||||||
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
|
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onTogglePublishStatus={(nextStatus) =>
|
||||||
|
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
|
||||||
|
}
|
||||||
|
publishStatusUpdating={publishStatusLessonId === lesson.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -465,12 +593,66 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Practice Cards Grid */}
|
{practicesLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
|
||||||
{practices.map((practice) => (
|
Loading practices…
|
||||||
<PracticeCard key={practice.id} {...practice} />
|
</div>
|
||||||
))}
|
) : practicesLoadError ? (
|
||||||
</div>
|
<div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
|
||||||
|
{practicesLoadError}
|
||||||
|
</div>
|
||||||
|
) : filteredPractices.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{filteredPractices.map((practice) => (
|
||||||
|
<ModulePracticeCard
|
||||||
|
key={practice.id}
|
||||||
|
practice={practice}
|
||||||
|
statusUpdating={publishStatusPracticeId === practice.id}
|
||||||
|
onEdit={() =>
|
||||||
|
navigate(
|
||||||
|
`/content/practices?type=module&id=${moduleId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPublish={() => void handlePublishPractice(practice.id)}
|
||||||
|
onSaveAsDraft={() =>
|
||||||
|
void handleSavePracticeAsDraft(practice.id)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
|
||||||
|
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
|
||||||
|
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
|
||||||
|
<Calendar className="h-7 w-7 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
|
||||||
|
{practices.length === 0
|
||||||
|
? "No practices in this module yet"
|
||||||
|
: "No practices match this filter"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
|
||||||
|
{practices.length === 0
|
||||||
|
? "Add a practice to give learners speaking exercises for this module."
|
||||||
|
: "Try another status filter or add a new practice."}
|
||||||
|
</p>
|
||||||
|
{practices.length === 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Add Practice
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -512,6 +694,28 @@ export function ModuleDetailPage() {
|
||||||
disabled={savingLessonEdit}
|
disabled={savingLessonEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
className="text-sm font-medium text-grayScale-700"
|
||||||
|
htmlFor="edit-lesson-sort-order"
|
||||||
|
>
|
||||||
|
Sort order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-lesson-sort-order"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={editLessonSortOrder}
|
||||||
|
onChange={(e) => setEditLessonSortOrder(e.target.value)}
|
||||||
|
disabled={savingLessonEdit}
|
||||||
|
className="max-w-[200px]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
Whole number, 0 or greater.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<LessonMediaUploadField
|
<LessonMediaUploadField
|
||||||
kind="video"
|
kind="video"
|
||||||
value={editLessonVideoUrl}
|
value={editLessonVideoUrl}
|
||||||
|
|
@ -620,68 +824,3 @@ export function ModuleDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PracticeCard({
|
|
||||||
title,
|
|
||||||
level,
|
|
||||||
variations,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
level: string;
|
|
||||||
variations: number;
|
|
||||||
status: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
|
|
||||||
<div className="flex-1 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
|
|
||||||
{level}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 text-grayScale-500">
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
<span className="text-[13px] font-bold">Speaking</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
|
|
||||||
<Layers className="h-4 w-4" />
|
|
||||||
<span className="text-[14px] font-bold">{variations} Variations</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
|
|
||||||
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
|
|
||||||
<Edit2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
|
|
||||||
<Trash2 className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-2 gap-3">
|
|
||||||
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
|
|
||||||
Publish Practice
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
|
|
||||||
>
|
|
||||||
Publish Video
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,31 @@ export function NewContentPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
<div>
|
||||||
Content Management
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||||
</h1>
|
Content Management
|
||||||
<p className="mt-1 text-sm text-grayScale-500">
|
</h1>
|
||||||
Upload, organize, and manage learning content across programs and
|
<p className="mt-1 text-sm text-grayScale-500">
|
||||||
courses
|
Upload, organize, and manage learning content across programs and
|
||||||
</p>
|
courses
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
|
||||||
|
<Link to="/new-content/question-types">
|
||||||
|
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all">
|
||||||
|
Manage Question Types
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/new-content/reorder">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 px-6 rounded-[6px] border-brand-500 font-bold text-brand-500 hover:bg-brand-50 transition-all"
|
||||||
|
>
|
||||||
|
Reorder Content
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ArrowLeft, Plus, FileText, 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";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import uploadIcon from "../../assets/icons/upload.png";
|
import uploadIcon from "../../assets/icons/upload.png";
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
|
||||||
import alertSrc from "../../assets/Alert.svg";
|
import alertSrc from "../../assets/Alert.svg";
|
||||||
|
|
@ -52,7 +51,6 @@ export function ProgramCoursesPage() {
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editSortOrder, setEditSortOrder] = useState("");
|
const [editSortOrder, setEditSortOrder] = useState("");
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
@ -61,7 +59,6 @@ export function ProgramCoursesPage() {
|
||||||
|
|
||||||
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
const [createCourseOpen, setCreateCourseOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createSortOrder, setCreateSortOrder] = useState("");
|
const [createSortOrder, setCreateSortOrder] = useState("");
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createSaving, setCreateSaving] = useState(false);
|
const [createSaving, setCreateSaving] = useState(false);
|
||||||
|
|
@ -136,7 +133,6 @@ export function ProgramCoursesPage() {
|
||||||
const openEditCourse = (course: ProgramCourseListItem) => {
|
const openEditCourse = (course: ProgramCourseListItem) => {
|
||||||
setEditingCourse(course);
|
setEditingCourse(course);
|
||||||
setEditName(course.name ?? "");
|
setEditName(course.name ?? "");
|
||||||
setEditDescription(course.description?.trim() ?? "");
|
|
||||||
setEditThumbnail(
|
setEditThumbnail(
|
||||||
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
|
||||||
);
|
);
|
||||||
|
|
@ -146,7 +142,6 @@ export function ProgramCoursesPage() {
|
||||||
const closeEditCourse = () => {
|
const closeEditCourse = () => {
|
||||||
setEditingCourse(null);
|
setEditingCourse(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
|
||||||
setEditSortOrder("");
|
setEditSortOrder("");
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setUploadingEditThumbnail(false);
|
setUploadingEditThumbnail(false);
|
||||||
|
|
@ -211,7 +206,7 @@ export function ProgramCoursesPage() {
|
||||||
try {
|
try {
|
||||||
await updateTopLevelCourse(editingCourse.id, {
|
await updateTopLevelCourse(editingCourse.id, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim(),
|
description: editingCourse.description?.trim() ?? "",
|
||||||
thumbnail: editThumbnail.trim(),
|
thumbnail: editThumbnail.trim(),
|
||||||
sort_order,
|
sort_order,
|
||||||
});
|
});
|
||||||
|
|
@ -231,7 +226,6 @@ export function ProgramCoursesPage() {
|
||||||
|
|
||||||
const clearCreateCourseForm = () => {
|
const clearCreateCourseForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
|
||||||
setCreateSortOrder("");
|
setCreateSortOrder("");
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateUploadingThumbnail(false);
|
setCreateUploadingThumbnail(false);
|
||||||
|
|
@ -302,7 +296,7 @@ export function ProgramCoursesPage() {
|
||||||
try {
|
try {
|
||||||
await createProgramCourse(programId, {
|
await createProgramCourse(programId, {
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim(),
|
description: "",
|
||||||
thumbnail: createThumbnail.trim(),
|
thumbnail: createThumbnail.trim(),
|
||||||
sort_order,
|
sort_order,
|
||||||
});
|
});
|
||||||
|
|
@ -365,18 +359,6 @@ export function ProgramCoursesPage() {
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{programIdValid ? (
|
{programIdValid ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
|
||||||
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-[6px] border-brand-500 text-brand-500 "
|
|
||||||
>
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Add Practice
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createCourseOpen}
|
open={createCourseOpen}
|
||||||
onOpenChange={handleCreateCourseDialogOpenChange}
|
onOpenChange={handleCreateCourseDialogOpenChange}
|
||||||
|
|
@ -449,20 +431,6 @@ export function ProgramCoursesPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Short summary of the course"
|
|
||||||
rows={3}
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={createSaving || createUploadingThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="create-course-sort-order"
|
htmlFor="create-course-sort-order"
|
||||||
|
|
@ -740,7 +708,7 @@ export function ProgramCoursesPage() {
|
||||||
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
|
||||||
<DialogTitle>Edit course</DialogTitle>
|
<DialogTitle>Edit course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, description, sort order, and thumbnail. Saved with{" "}
|
Update name, sort order, and thumbnail. Saved with{" "}
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
||||||
PUT /courses/:id
|
PUT /courses/:id
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -761,19 +729,6 @@ export function ProgramCoursesPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y rounded-xl"
|
|
||||||
placeholder="Short summary"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="edit-course-sort-order"
|
htmlFor="edit-course-sort-order"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -39,7 +38,6 @@ export function ProgramDetailPage() {
|
||||||
const { programType } = useParams<{ programType: string }>();
|
const { programType } = useParams<{ programType: string }>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -60,7 +58,6 @@ export function ProgramDetailPage() {
|
||||||
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("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
const [savingEdit, setSavingEdit] = useState(false);
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
|
@ -216,7 +213,7 @@ export function ProgramDetailPage() {
|
||||||
|
|
||||||
const response = await createExamPrepCatalogCourse({
|
const response = await createExamPrepCatalogCourse({
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim() || null,
|
description: null,
|
||||||
thumbnail: thumbnailToSend,
|
thumbnail: thumbnailToSend,
|
||||||
});
|
});
|
||||||
const row = response.data?.data;
|
const row = response.data?.data;
|
||||||
|
|
@ -227,7 +224,7 @@ export function ProgramDetailPage() {
|
||||||
{
|
{
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name ?? name,
|
name: row.name ?? name,
|
||||||
description: row.description?.trim() || createDescription.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),
|
||||||
unitsCount: Number(row.units_count ?? 0),
|
unitsCount: Number(row.units_count ?? 0),
|
||||||
|
|
@ -239,7 +236,6 @@ export function ProgramDetailPage() {
|
||||||
await loadCatalogCourses();
|
await loadCatalogCourses();
|
||||||
toast.success("Course created");
|
toast.success("Course created");
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateThumbnailFromUpload(false);
|
setCreateThumbnailFromUpload(false);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
|
@ -259,7 +255,6 @@ export function ProgramDetailPage() {
|
||||||
if (!Number.isFinite(idNum)) return;
|
if (!Number.isFinite(idNum)) return;
|
||||||
setEditingCourseId(idNum);
|
setEditingCourseId(idNum);
|
||||||
setEditName(String(course.name ?? ""));
|
setEditName(String(course.name ?? ""));
|
||||||
setEditDescription(String(course.description ?? ""));
|
|
||||||
setEditThumbnail(String(course.thumbnail ?? ""));
|
setEditThumbnail(String(course.thumbnail ?? ""));
|
||||||
setEditSortOrder(String(course.sort_order ?? 1));
|
setEditSortOrder(String(course.sort_order ?? 1));
|
||||||
};
|
};
|
||||||
|
|
@ -268,7 +263,6 @@ export function ProgramDetailPage() {
|
||||||
if (savingEdit || uploadingEditThumbnail) return;
|
if (savingEdit || uploadingEditThumbnail) return;
|
||||||
setEditingCourseId(null);
|
setEditingCourseId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditSortOrder("1");
|
setEditSortOrder("1");
|
||||||
};
|
};
|
||||||
|
|
@ -317,9 +311,14 @@ export function ProgramDetailPage() {
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
|
||||||
|
const existing = createdCourses.find((c) => c.id === editingCourseId);
|
||||||
|
const preservedDescription =
|
||||||
|
existing?.description && existing.description !== "—"
|
||||||
|
? existing.description
|
||||||
|
: null;
|
||||||
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
const response = await updateExamPrepCatalogCourse(editingCourseId, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim() || null,
|
description: preservedDescription,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
sort_order: sortOrderNum,
|
sort_order: sortOrderNum,
|
||||||
});
|
});
|
||||||
|
|
@ -330,7 +329,7 @@ export function ProgramDetailPage() {
|
||||||
? {
|
? {
|
||||||
...course,
|
...course,
|
||||||
name: row?.name ?? name,
|
name: row?.name ?? name,
|
||||||
description: row?.description?.trim() || editDescription.trim() || "—",
|
description: row?.description?.trim() || preservedDescription || "—",
|
||||||
thumbnail: row?.thumbnail?.trim() || null,
|
thumbnail: row?.thumbnail?.trim() || null,
|
||||||
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
sortOrder: Number(row?.sort_order ?? sortOrderNum),
|
||||||
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
|
||||||
|
|
@ -467,20 +466,6 @@ export function ProgramDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Optional description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">
|
<label className="text-[15px] text-grayScale-800">
|
||||||
Thumbnail
|
Thumbnail
|
||||||
|
|
@ -735,17 +720,6 @@ export function ProgramDetailPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,18 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { GraduationCap, Brain } from "lucide-react";
|
import { GraduationCap, Brain } from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
|
|
||||||
export function ProgramTypeSelectionPage() {
|
export function ProgramTypeSelectionPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="space-y-1.5 pt-2">
|
||||||
<div className="space-y-1.5 pt-2">
|
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
||||||
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
|
Courses
|
||||||
Courses
|
</h1>
|
||||||
</h1>
|
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
||||||
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
|
Organize courses under skill-based learning or English proficiency
|
||||||
Organize courses under skill-based learning or English proficiency
|
exams. Select a program type to manage curriculum and modules.
|
||||||
exams. Select a program type to manage curriculum and modules.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/new-content/question-types">
|
|
||||||
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
|
|
||||||
Manage Question Types
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
|
||||||
|
|
@ -113,11 +113,11 @@ export function QuestionTypeLibraryPage() {
|
||||||
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link
|
<Link
|
||||||
to="/new-content/courses"
|
to="/new-content"
|
||||||
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||||
Back to Courses
|
Back to Content Management
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
|
|
||||||
31
src/pages/content-management/ReorderContentPage.tsx
Normal file
31
src/pages/content-management/ReorderContentPage.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
||||||
|
|
||||||
|
export function ReorderContentPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
to="/new-content"
|
||||||
|
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
|
||||||
|
Back to Content Management
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
|
||||||
|
Reorder Content
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||||
|
Drag and drop programs, courses, modules, and lessons to change
|
||||||
|
their display order.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContentHierarchyList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
import { Textarea } from "../../components/ui/textarea";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -55,7 +54,6 @@ export function UnitManagementPage() {
|
||||||
const parsedUnitId = Number(unitId);
|
const parsedUnitId = Number(unitId);
|
||||||
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
const [addModuleOpen, setAddModuleOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState("");
|
const [createName, setCreateName] = useState("");
|
||||||
const [createDescription, setCreateDescription] = useState("");
|
|
||||||
const [createThumbnail, setCreateThumbnail] = useState("");
|
const [createThumbnail, setCreateThumbnail] = useState("");
|
||||||
const [createIcon, setCreateIcon] = useState("");
|
const [createIcon, setCreateIcon] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -79,7 +77,6 @@ export function UnitManagementPage() {
|
||||||
>([]);
|
>([]);
|
||||||
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
|
||||||
const [editThumbnail, setEditThumbnail] = useState("");
|
const [editThumbnail, setEditThumbnail] = useState("");
|
||||||
const [editIcon, setEditIcon] = useState("");
|
const [editIcon, setEditIcon] = useState("");
|
||||||
const [editSortOrder, setEditSortOrder] = useState("1");
|
const [editSortOrder, setEditSortOrder] = useState("1");
|
||||||
|
|
@ -159,7 +156,6 @@ export function UnitManagementPage() {
|
||||||
|
|
||||||
const clearCreateModuleForm = () => {
|
const clearCreateModuleForm = () => {
|
||||||
setCreateName("");
|
setCreateName("");
|
||||||
setCreateDescription("");
|
|
||||||
setCreateThumbnail("");
|
setCreateThumbnail("");
|
||||||
setCreateIcon("");
|
setCreateIcon("");
|
||||||
if (createThumbnailFileInputRef.current) {
|
if (createThumbnailFileInputRef.current) {
|
||||||
|
|
@ -264,7 +260,7 @@ export function UnitManagementPage() {
|
||||||
const minioIcon = await resolveToMinioUrl(createIcon);
|
const minioIcon = await resolveToMinioUrl(createIcon);
|
||||||
await createExamPrepUnitModule(parsedUnitId, {
|
await createExamPrepUnitModule(parsedUnitId, {
|
||||||
name,
|
name,
|
||||||
description: createDescription.trim() || null,
|
description: null,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
icon: minioIcon || null,
|
icon: minioIcon || null,
|
||||||
});
|
});
|
||||||
|
|
@ -286,7 +282,6 @@ export function UnitManagementPage() {
|
||||||
const openEditModule = (module: (typeof modules)[number]) => {
|
const openEditModule = (module: (typeof modules)[number]) => {
|
||||||
setEditingModuleId(module.id);
|
setEditingModuleId(module.id);
|
||||||
setEditName(module.name ?? "");
|
setEditName(module.name ?? "");
|
||||||
setEditDescription(module.description ?? "");
|
|
||||||
setEditThumbnail(module.thumbnail ?? "");
|
setEditThumbnail(module.thumbnail ?? "");
|
||||||
setEditIcon(module.icon ?? "");
|
setEditIcon(module.icon ?? "");
|
||||||
setEditSortOrder(String(module.sortOrder ?? 1));
|
setEditSortOrder(String(module.sortOrder ?? 1));
|
||||||
|
|
@ -296,7 +291,6 @@ export function UnitManagementPage() {
|
||||||
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
|
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
|
||||||
setEditingModuleId(null);
|
setEditingModuleId(null);
|
||||||
setEditName("");
|
setEditName("");
|
||||||
setEditDescription("");
|
|
||||||
setEditThumbnail("");
|
setEditThumbnail("");
|
||||||
setEditIcon("");
|
setEditIcon("");
|
||||||
setEditSortOrder("1");
|
setEditSortOrder("1");
|
||||||
|
|
@ -391,11 +385,16 @@ export function UnitManagementPage() {
|
||||||
|
|
||||||
setSavingEdit(true);
|
setSavingEdit(true);
|
||||||
try {
|
try {
|
||||||
|
const existing = modules.find((m) => m.id === editingModuleId);
|
||||||
|
const preservedDescription =
|
||||||
|
existing?.description && existing.description !== "—"
|
||||||
|
? existing.description
|
||||||
|
: null;
|
||||||
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
|
const minioThumbnail = await resolveToMinioUrl(editThumbnail);
|
||||||
const minioIcon = await resolveToMinioUrl(editIcon);
|
const minioIcon = await resolveToMinioUrl(editIcon);
|
||||||
await updateExamPrepUnitModule(editingModuleId, {
|
await updateExamPrepUnitModule(editingModuleId, {
|
||||||
name,
|
name,
|
||||||
description: editDescription.trim() || null,
|
description: preservedDescription,
|
||||||
thumbnail: minioThumbnail || null,
|
thumbnail: minioThumbnail || null,
|
||||||
icon: minioIcon || null,
|
icon: minioIcon || null,
|
||||||
sort_order: sortOrderNum,
|
sort_order: sortOrderNum,
|
||||||
|
|
@ -489,20 +488,6 @@ export function UnitManagementPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={createDescription}
|
|
||||||
onChange={(e) => setCreateDescription(e.target.value)}
|
|
||||||
placeholder="Optional module description"
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={creating || uploadingThumbnail || uploadingIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
<label className="text-[15px] text-grayScale-800">Thumbnail</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -812,16 +797,6 @@ export function UnitManagementPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-[15px] text-grayScale-800">Description</label>
|
|
||||||
<Textarea
|
|
||||||
value={editDescription}
|
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[96px] rounded-[8px] border-grayScale-400"
|
|
||||||
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
<label className="text-[15px] text-grayScale-800">Sort Order</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
import { createTopLevelCourseModule } from "../../../api/courses.api";
|
||||||
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
import { ModuleIconUploadField } from "./ModuleIconUploadField";
|
||||||
|
|
@ -28,7 +27,6 @@ export function AddModuleModal({
|
||||||
onCreated,
|
onCreated,
|
||||||
}: AddModuleModalProps) {
|
}: AddModuleModalProps) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [sortOrder, setSortOrder] = useState("");
|
const [sortOrder, setSortOrder] = useState("");
|
||||||
const [icon, setIcon] = useState("");
|
const [icon, setIcon] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -37,7 +35,6 @@ export function AddModuleModal({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
|
||||||
setSortOrder("");
|
setSortOrder("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -47,7 +44,6 @@ export function AddModuleModal({
|
||||||
|
|
||||||
const resetAndClose = () => {
|
const resetAndClose = () => {
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
|
||||||
setSortOrder("");
|
setSortOrder("");
|
||||||
setIcon("");
|
setIcon("");
|
||||||
setIconUploadBusy(false);
|
setIconUploadBusy(false);
|
||||||
|
|
@ -86,7 +82,7 @@ export function AddModuleModal({
|
||||||
try {
|
try {
|
||||||
await createTopLevelCourseModule(courseId, {
|
await createTopLevelCourseModule(courseId, {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
description: description.trim(),
|
description: "",
|
||||||
icon: icon.trim(),
|
icon: icon.trim(),
|
||||||
sort_order,
|
sort_order,
|
||||||
});
|
});
|
||||||
|
|
@ -157,20 +153,6 @@ export function AddModuleModal({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[15px] font-medium text-grayScale-700">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Learn to introduce yourself and talk about your life."
|
|
||||||
className="min-h-[88px] resize-y rounded-xl"
|
|
||||||
disabled={submitting}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="create-module-sort-order"
|
htmlFor="create-module-sort-order"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
PracticeSequentialReview,
|
||||||
|
type PracticeReviewQuestion,
|
||||||
|
} from "./practice-steps/PracticeSequentialReview";
|
||||||
|
import type { PersonaCardModel } from "../../../lib/personaDisplay";
|
||||||
|
|
||||||
|
type IntroVideoPreview =
|
||||||
|
| { kind: "vimeo"; url: string }
|
||||||
|
| { kind: "video"; url: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function plainTextFromHtml(raw: string): string {
|
||||||
|
if (!raw.trim()) return "";
|
||||||
|
if (!/<\/?[a-z][\s\S]*>/i.test(raw)) return raw.trim();
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(raw, "text/html");
|
||||||
|
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||||
|
} catch {
|
||||||
|
return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddNewPracticeReviewStepProps = {
|
||||||
|
practiceTitle: string;
|
||||||
|
practiceDescription: string;
|
||||||
|
selectedProgram: string;
|
||||||
|
selectedCourse: string;
|
||||||
|
moduleLabel: string;
|
||||||
|
selectedPersona: string | null;
|
||||||
|
personas: PersonaCardModel[];
|
||||||
|
introVideoPreview: IntroVideoPreview;
|
||||||
|
questions: PracticeReviewQuestion[];
|
||||||
|
saving: boolean;
|
||||||
|
saveError: string | null;
|
||||||
|
onEditContext: () => void;
|
||||||
|
onEditQuestions: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddNewPracticeReviewStep({
|
||||||
|
practiceTitle,
|
||||||
|
practiceDescription,
|
||||||
|
selectedProgram,
|
||||||
|
selectedCourse,
|
||||||
|
moduleLabel,
|
||||||
|
selectedPersona,
|
||||||
|
personas,
|
||||||
|
introVideoPreview,
|
||||||
|
questions,
|
||||||
|
saving,
|
||||||
|
saveError,
|
||||||
|
onEditContext,
|
||||||
|
onEditQuestions,
|
||||||
|
onBack,
|
||||||
|
onSaveDraft,
|
||||||
|
onPublish,
|
||||||
|
}: AddNewPracticeReviewStepProps) {
|
||||||
|
const persona = personas.find((p) => p.id === selectedPersona);
|
||||||
|
|
||||||
|
const guidanceText = useMemo(() => {
|
||||||
|
const fromDescription = plainTextFromHtml(practiceDescription);
|
||||||
|
if (fromDescription) return fromDescription;
|
||||||
|
const tips = questions
|
||||||
|
.map((q) => q.tips?.trim() ?? "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return tips || "—";
|
||||||
|
}, [practiceDescription, questions]);
|
||||||
|
|
||||||
|
const thumbnailKind =
|
||||||
|
introVideoPreview?.kind === "video"
|
||||||
|
? "video"
|
||||||
|
: introVideoPreview?.kind === "vimeo"
|
||||||
|
? "vimeo"
|
||||||
|
: "gradient";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PracticeSequentialReview
|
||||||
|
practiceTitle={practiceTitle}
|
||||||
|
thumbnailUrl={
|
||||||
|
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
|
||||||
|
}
|
||||||
|
thumbnailKind={thumbnailKind}
|
||||||
|
persona={persona ?? null}
|
||||||
|
metadata={[
|
||||||
|
{ label: "Program", value: selectedProgram },
|
||||||
|
{ label: "Course", value: selectedCourse },
|
||||||
|
{ label: "Module", value: moduleLabel },
|
||||||
|
]}
|
||||||
|
guidanceText={guidanceText}
|
||||||
|
questions={questions}
|
||||||
|
saving={saving}
|
||||||
|
saveError={saveError}
|
||||||
|
onEditContext={onEditContext}
|
||||||
|
onEditQuestions={onEditQuestions}
|
||||||
|
onBack={onBack}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
|
onPublish={onPublish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,6 @@ import type {
|
||||||
PracticeParentKind,
|
PracticeParentKind,
|
||||||
PracticePublishStatus,
|
PracticePublishStatus,
|
||||||
} from "../../../types/course.types"
|
} from "../../../types/course.types"
|
||||||
import { PublishStatusField } from "./practice-steps/PublishStatusField"
|
|
||||||
import { cn } from "../../../lib/utils"
|
import { cn } from "../../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
|
||||||
|
|
||||||
|
|
@ -65,7 +64,8 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
const [storyDescription, setStoryDescription] = useState("")
|
const [storyDescription, setStoryDescription] = useState("")
|
||||||
const [storyImage, setStoryImage] = useState("")
|
const [storyImage, setStoryImage] = useState("")
|
||||||
const [quickTips, setQuickTips] = useState("")
|
const [quickTips, setQuickTips] = useState("")
|
||||||
const [publishStatus, setPublishStatus] = useState<PracticePublishStatus>("DRAFT")
|
const [pendingSaveStatus, setPendingSaveStatus] =
|
||||||
|
useState<PracticePublishStatus | null>(null)
|
||||||
|
|
||||||
const canUseWizard = parent != null
|
const canUseWizard = parent != null
|
||||||
|
|
||||||
|
|
@ -85,7 +85,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
setStoryDescription("")
|
setStoryDescription("")
|
||||||
setStoryImage("")
|
setStoryImage("")
|
||||||
setQuickTips("")
|
setQuickTips("")
|
||||||
setPublishStatus("DRAFT")
|
setPendingSaveStatus(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleStep1 = async () => {
|
const handleStep1 = async () => {
|
||||||
|
|
@ -186,6 +186,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
toast.error("Title, story description, and story image are required")
|
toast.error("Title, story description, and story image are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setPendingSaveStatus(status)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await createParentLinkedPractice({
|
await createParentLinkedPractice({
|
||||||
|
|
@ -208,6 +209,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
|
toast.error(err.response?.data?.message || err.message || "Failed to create practice")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
setPendingSaveStatus(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,11 +475,6 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PublishStatusField
|
|
||||||
value={publishStatus}
|
|
||||||
onChange={setPublishStatus}
|
|
||||||
disabled={saving}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
<Button type="button" variant="outline" onClick={() => setStep(3)} disabled={saving}>
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
|
@ -487,12 +484,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => void handleStep4("DRAFT")}
|
||||||
setPublishStatus("DRAFT")
|
|
||||||
void handleStep4("DRAFT")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving && publishStatus === "DRAFT" ? (
|
{saving && pendingSaveStatus === "DRAFT" ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : null}
|
) : null}
|
||||||
Save as draft
|
Save as draft
|
||||||
|
|
@ -500,12 +494,9 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => void handleStep4("PUBLISHED")}
|
||||||
setPublishStatus("PUBLISHED")
|
|
||||||
void handleStep4("PUBLISHED")
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saving && publishStatus === "PUBLISHED" ? (
|
{saving && pendingSaveStatus === "PUBLISHED" ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
) : null}
|
) : null}
|
||||||
Publish practice
|
Publish practice
|
||||||
|
|
|
||||||
155
src/pages/content-management/components/ModulePracticeCard.tsx
Normal file
155
src/pages/content-management/components/ModulePracticeCard.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Edit2, Loader2, MoreVertical } from "lucide-react";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { Card } from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../../../components/ui/dropdown-menu";
|
||||||
|
import { ResolvedImage } from "../../../components/media/ResolvedImage";
|
||||||
|
import type { ParentContextPractice } from "../../../types/course.types";
|
||||||
|
import {
|
||||||
|
isPracticePublished,
|
||||||
|
practicePublishStatus,
|
||||||
|
} from "../../../lib/parentContextPractice";
|
||||||
|
import { resolveThumbnailForPreview } from "../../../lib/videoPreview";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
|
||||||
|
type ModulePracticeCardProps = {
|
||||||
|
practice: ParentContextPractice;
|
||||||
|
statusUpdating?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onPublish?: () => void;
|
||||||
|
onSaveAsDraft?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModulePracticeCard({
|
||||||
|
practice,
|
||||||
|
statusUpdating = false,
|
||||||
|
onEdit,
|
||||||
|
onPublish,
|
||||||
|
onSaveAsDraft,
|
||||||
|
}: ModulePracticeCardProps) {
|
||||||
|
const isPublished = isPracticePublished(practice);
|
||||||
|
const statusLabel = practicePublishStatus(practice) ?? "DRAFT";
|
||||||
|
const thumbnailSrc = useMemo(
|
||||||
|
() => resolveThumbnailForPreview(practice.story_image),
|
||||||
|
[practice.story_image],
|
||||||
|
);
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThumbFailed(false);
|
||||||
|
}, [thumbnailSrc]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="group flex flex-col overflow-hidden rounded-[20px] border border-grayScale-50 bg-white shadow-sm transition-all hover:shadow-xl hover:shadow-grayScale-400/5">
|
||||||
|
<div className="relative h-44 w-full overflow-hidden bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]">
|
||||||
|
{thumbnailSrc && !thumbFailed ? (
|
||||||
|
<ResolvedImage
|
||||||
|
src={thumbnailSrc}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
onError={() => setThumbFailed(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col space-y-5 p-5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider",
|
||||||
|
isPublished
|
||||||
|
? "border-[#DCFCE7] bg-[#F0FDF4] text-[#16A34A]"
|
||||||
|
: "border-grayScale-100 bg-grayScale-50 text-grayScale-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||||
|
isPublished ? "bg-[#16A34A]" : "bg-grayScale-300",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{statusLabel}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
|
||||||
|
disabled={statusUpdating}
|
||||||
|
aria-label={`Practice options: ${practice.title}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{statusUpdating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={statusUpdating}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isPublished) {
|
||||||
|
onSaveAsDraft?.();
|
||||||
|
} else {
|
||||||
|
onPublish?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPublished ? "Save as draft" : "Publish practice"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="line-clamp-3 min-h-[2.75rem] text-[14px] font-bold leading-snug text-[#0F172A]">
|
||||||
|
{practice.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="mt-auto grid grid-cols-1 gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full rounded-[10px] border-brand-500 text-[12px] font-bold text-brand-500 hover:bg-brand-50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isPublished || statusUpdating}
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full rounded-[10px] text-[12px] font-bold shadow-sm transition-all",
|
||||||
|
isPublished
|
||||||
|
? "cursor-default bg-[#ECD5E9] text-[#9E2891] hover:bg-[#ECD5E9]"
|
||||||
|
: "bg-brand-500 text-white hover:bg-brand-600",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isPublished) onPublish?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusUpdating
|
||||||
|
? "Updating…"
|
||||||
|
: isPublished
|
||||||
|
? "Published"
|
||||||
|
: "Publish"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getPracticesByParentCourse,
|
getPracticesByParentCourse,
|
||||||
getPracticesByParentModule,
|
getPracticesByParentModule,
|
||||||
publishParentLinkedPractice,
|
publishParentLinkedPractice,
|
||||||
|
updateParentLinkedPractice,
|
||||||
} from "../../../api/courses.api"
|
} from "../../../api/courses.api"
|
||||||
import type { PracticeParentKind } from "../../../types/course.types"
|
import type { PracticeParentKind } from "../../../types/course.types"
|
||||||
import { Button } from "../../../components/ui/button"
|
import { Button } from "../../../components/ui/button"
|
||||||
|
|
@ -29,7 +30,7 @@ export function PublishPracticeButton({
|
||||||
onPublished,
|
onPublished,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [publishing, setPublishing] = useState(false)
|
const [acting, setActing] = useState(false)
|
||||||
const [hasDraft, setHasDraft] = useState(false)
|
const [hasDraft, setHasDraft] = useState(false)
|
||||||
const [allPublished, setAllPublished] = useState(false)
|
const [allPublished, setAllPublished] = useState(false)
|
||||||
const [hasPractice, setHasPractice] = useState(false)
|
const [hasPractice, setHasPractice] = useState(false)
|
||||||
|
|
@ -68,9 +69,11 @@ export function PublishPracticeButton({
|
||||||
void loadPractices()
|
void loadPractices()
|
||||||
}, [loadPractices])
|
}, [loadPractices])
|
||||||
|
|
||||||
|
const isDraftMode = allPublished
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!Number.isFinite(parentId) || parentId < 1) return
|
if (!Number.isFinite(parentId) || parentId < 1) return
|
||||||
setPublishing(true)
|
setActing(true)
|
||||||
try {
|
try {
|
||||||
const res =
|
const res =
|
||||||
parentKind === "COURSE"
|
parentKind === "COURSE"
|
||||||
|
|
@ -98,34 +101,82 @@ export function PublishPracticeButton({
|
||||||
?.message ?? "Failed to publish practice"
|
?.message ?? "Failed to publish practice"
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setPublishing(false)
|
setActing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveAsDraft = async () => {
|
||||||
|
if (!Number.isFinite(parentId) || parentId < 1) return
|
||||||
|
setActing(true)
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
parentKind === "COURSE"
|
||||||
|
? await getPracticesByParentCourse(parentId, { limit: 50, offset: 0 })
|
||||||
|
: await getPracticesByParentModule(parentId, { limit: 50, offset: 0 })
|
||||||
|
const toDraft = unwrapPracticesList(res).filter(isPracticePublished)
|
||||||
|
if (toDraft.length === 0) {
|
||||||
|
toast.info("No published practice to save as draft")
|
||||||
|
await loadPractices()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const practice of toDraft) {
|
||||||
|
await updateParentLinkedPractice(practice.id, {
|
||||||
|
publish_status: "DRAFT",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
toDraft.length === 1
|
||||||
|
? "Practice saved as draft"
|
||||||
|
: `${toDraft.length} practices saved as draft`,
|
||||||
|
)
|
||||||
|
await loadPractices()
|
||||||
|
onPublished?.()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to save practice as draft"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setActing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled =
|
const disabled =
|
||||||
loading || publishing || !hasPractice || !hasDraft || allPublished
|
loading ||
|
||||||
|
acting ||
|
||||||
|
!hasPractice ||
|
||||||
|
(!hasDraft && !allPublished)
|
||||||
|
|
||||||
let label = "Publish Practice"
|
let label = "Publish Practice"
|
||||||
if (loading) label = "Loading…"
|
if (loading) label = "Loading…"
|
||||||
else if (publishing) label = "Publishing…"
|
else if (acting) label = isDraftMode ? "Saving…" : "Publishing…"
|
||||||
else if (!hasPractice) label = "No practice"
|
else if (allPublished) label = "Save as Draft"
|
||||||
else if (allPublished) label = "Published"
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isDraftMode) {
|
||||||
|
void handleSaveAsDraft()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void handlePublish()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => void handlePublish()}
|
onClick={handleClick}
|
||||||
title={
|
title={
|
||||||
allPublished
|
!hasPractice
|
||||||
? "Practice is already published"
|
? "No practice linked to this course yet"
|
||||||
: !hasPractice
|
: allPublished
|
||||||
? "No practice linked to this item yet"
|
? "Move published practice back to draft"
|
||||||
: undefined
|
: hasDraft
|
||||||
|
? "Publish draft practice"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(loading || publishing) && (
|
{(loading || acting) && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,19 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Calendar,
|
Calendar,
|
||||||
Edit2,
|
Edit2,
|
||||||
|
Loader2,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../../../components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -21,17 +28,46 @@ import {
|
||||||
applyShortPreviewToEmbedUrl,
|
applyShortPreviewToEmbedUrl,
|
||||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||||
formatPreviewLength,
|
formatPreviewLength,
|
||||||
|
formatVideoDurationLabel,
|
||||||
getVideoPreview,
|
getVideoPreview,
|
||||||
|
isDirectVideoFileUrl,
|
||||||
} from "../../../lib/videoPreview";
|
} from "../../../lib/videoPreview";
|
||||||
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
import { PreviewLimitedFileVideo } from "./PreviewLimitedFileVideo";
|
||||||
|
import type { PracticePublishStatus } from "../../../types/course.types";
|
||||||
|
|
||||||
|
function resolvePublishBadge(
|
||||||
|
publishStatus?: PracticePublishStatus | string | null,
|
||||||
|
status?: "Draft" | "Published",
|
||||||
|
hoverModuleActions?: boolean,
|
||||||
|
): { label: string; isPublished: boolean } | null {
|
||||||
|
const raw =
|
||||||
|
publishStatus ??
|
||||||
|
(status === "Published"
|
||||||
|
? "PUBLISHED"
|
||||||
|
: status === "Draft"
|
||||||
|
? "DRAFT"
|
||||||
|
: null);
|
||||||
|
if (raw) {
|
||||||
|
const label = String(raw).toUpperCase();
|
||||||
|
return { label, isPublished: label === "PUBLISHED" };
|
||||||
|
}
|
||||||
|
if (hoverModuleActions) {
|
||||||
|
return { label: "DRAFT", isPublished: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
/** Omits the duration chip when not provided (e.g. API has no length yet). */
|
||||||
duration?: string;
|
duration?: string;
|
||||||
|
/** Total seconds; shown when `duration` string is omitted. Direct file URLs may still be probed in-browser. */
|
||||||
|
durationSeconds?: number | null;
|
||||||
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
|
/** When omitted, shows a neutral "Lesson" chip and no Publish button. */
|
||||||
status?: "Draft" | "Published";
|
status?: "Draft" | "Published";
|
||||||
|
/** From GET lesson list — preferred for module lesson cards (`PUBLISHED` / `DRAFT`). */
|
||||||
|
publishStatus?: PracticePublishStatus | string | null;
|
||||||
thumbnailGradient?: string;
|
thumbnailGradient?: string;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
/**
|
/**
|
||||||
|
|
@ -51,6 +87,9 @@ interface VideoCardProps {
|
||||||
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
|
/** When set with hoverModuleActions, shows a book icon next to edit/delete on thumbnail hover. */
|
||||||
onViewPractices?: () => void;
|
onViewPractices?: () => void;
|
||||||
onPublish?: () => void;
|
onPublish?: () => void;
|
||||||
|
/** Toggle draft ↔ published via PUT /lessons/:id (module lesson cards). */
|
||||||
|
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
||||||
|
publishStatusUpdating?: 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;
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +97,9 @@ interface VideoCardProps {
|
||||||
export function VideoCard({
|
export function VideoCard({
|
||||||
title,
|
title,
|
||||||
duration,
|
duration,
|
||||||
|
durationSeconds,
|
||||||
status,
|
status,
|
||||||
|
publishStatus,
|
||||||
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
thumbnailGradient = "from-[#CBD5E1] to-[#94A3B8]",
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
|
|
@ -67,10 +108,15 @@ export function VideoCard({
|
||||||
onPublish,
|
onPublish,
|
||||||
onAddPractice,
|
onAddPractice,
|
||||||
onViewPractices,
|
onViewPractices,
|
||||||
|
onTogglePublishStatus,
|
||||||
|
publishStatusUpdating = false,
|
||||||
hoverModuleActions = false,
|
hoverModuleActions = false,
|
||||||
description,
|
description,
|
||||||
}: VideoCardProps) {
|
}: VideoCardProps) {
|
||||||
const [thumbFailed, setThumbFailed] = useState(false);
|
const [thumbFailed, setThumbFailed] = useState(false);
|
||||||
|
const [probedDurationSeconds, setProbedDurationSeconds] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
/** 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);
|
||||||
|
|
@ -92,6 +138,77 @@ export function VideoCard({
|
||||||
const previewLengthLabel = formatPreviewLength(
|
const previewLengthLabel = formatPreviewLength(
|
||||||
DEFAULT_PREVIEW_MAX_SECONDS,
|
DEFAULT_PREVIEW_MAX_SECONDS,
|
||||||
);
|
);
|
||||||
|
const publishBadge = resolvePublishBadge(
|
||||||
|
publishStatus,
|
||||||
|
status,
|
||||||
|
hoverModuleActions,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration?.trim()) {
|
||||||
|
setProbedDurationSeconds(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof durationSeconds === "number" &&
|
||||||
|
Number.isFinite(durationSeconds) &&
|
||||||
|
durationSeconds > 0
|
||||||
|
) {
|
||||||
|
setProbedDurationSeconds(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = videoUrl?.trim() ?? "";
|
||||||
|
if (!isDirectVideoFileUrl(url)) {
|
||||||
|
setProbedDurationSeconds(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.muted = true;
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const d = video.duration;
|
||||||
|
if (Number.isFinite(d) && d > 0 && !Number.isNaN(d)) {
|
||||||
|
setProbedDurationSeconds(d);
|
||||||
|
} else {
|
||||||
|
setProbedDurationSeconds(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
if (!cancelled) setProbedDurationSeconds(null);
|
||||||
|
};
|
||||||
|
video.addEventListener("loadedmetadata", onLoaded);
|
||||||
|
video.addEventListener("error", onError);
|
||||||
|
video.src = url;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
video.removeEventListener("loadedmetadata", onLoaded);
|
||||||
|
video.removeEventListener("error", onError);
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.load();
|
||||||
|
};
|
||||||
|
}, [duration, durationSeconds, videoUrl]);
|
||||||
|
|
||||||
|
const durationLabel = (() => {
|
||||||
|
const trimmed = duration?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const fromApi =
|
||||||
|
typeof durationSeconds === "number" &&
|
||||||
|
Number.isFinite(durationSeconds) &&
|
||||||
|
durationSeconds > 0
|
||||||
|
? durationSeconds
|
||||||
|
: null;
|
||||||
|
if (fromApi != null) return formatVideoDurationLabel(fromApi);
|
||||||
|
if (
|
||||||
|
probedDurationSeconds != null &&
|
||||||
|
Number.isFinite(probedDurationSeconds) &&
|
||||||
|
probedDurationSeconds > 0
|
||||||
|
) {
|
||||||
|
return formatVideoDurationLabel(probedDurationSeconds);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!previewOpen) {
|
if (!previewOpen) {
|
||||||
|
|
@ -198,10 +315,10 @@ export function VideoCard({
|
||||||
onError={() => setThumbFailed(true)}
|
onError={() => setThumbFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Duration Badge */}
|
{/* Duration — bottom-right on thumbnail */}
|
||||||
{duration ? (
|
{durationLabel ? (
|
||||||
<div className="absolute bottom-3 right-3 z-10 bg-black/70 text-white text-[11px] font-bold px-2 py-1 rounded-md backdrop-blur-sm">
|
<div className="pointer-events-none absolute bottom-2 right-2 z-[12] rounded bg-black/75 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-white shadow-sm backdrop-blur-sm">
|
||||||
{duration}
|
{durationLabel}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Play: opens preview dialog when videoUrl is set */}
|
{/* Play: opens preview dialog when videoUrl is set */}
|
||||||
|
|
@ -333,34 +450,69 @@ export function VideoCard({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-4 flex shrink-0 items-center gap-2",
|
"mb-4 flex shrink-0 items-center gap-2",
|
||||||
hoverModuleActions ? "justify-start" : "justify-between",
|
"justify-between",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Status Badge */}
|
{/* Publish status badge */}
|
||||||
{status ? (
|
{publishBadge ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border min-w-0",
|
"flex min-w-0 items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-wider",
|
||||||
status === "Published"
|
publishBadge.isPublished
|
||||||
? "bg-[#ECFDF5] text-[#059669] border-[#D1FAE5]"
|
? "border-[#D1FAE5] bg-[#ECFDF5] text-[#059669]"
|
||||||
: "bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]",
|
: "border-[#E5E7EB] bg-grayScale-50 text-grayScale-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-1.5 w-1.5 rounded-full flex-shrink-0",
|
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
|
||||||
status === "Published" ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
publishBadge.isPublished ? "bg-[#10B981]" : "bg-[#9CA3AF]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{status}
|
{publishBadge.label}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-w-0 items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold uppercase tracking-wider border border-[#E5E7EB] bg-grayScale-50 text-grayScale-500">
|
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#E5E7EB] bg-grayScale-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||||
<div className="h-1.5 w-1.5 rounded-full flex-shrink-0 bg-[#9CA3AF]" />
|
<div className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[#9CA3AF]" />
|
||||||
Lesson
|
Lesson
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!hoverModuleActions ? (
|
{hoverModuleActions && onTogglePublishStatus ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 flex-shrink-0 rounded-full text-grayScale-400 hover:bg-grayScale-50 hover:text-grayScale-600"
|
||||||
|
disabled={publishStatusUpdating}
|
||||||
|
aria-label={`Lesson options: ${title}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{publishStatusUpdating ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={publishStatusUpdating}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePublishStatus(
|
||||||
|
publishBadge?.isPublished ? "DRAFT" : "PUBLISHED",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{publishBadge?.isPublished
|
||||||
|
? "Save as draft"
|
||||||
|
: "Publish lesson"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : !hoverModuleActions ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
|
className="h-8 w-8 flex flex-shrink-0 items-center justify-center rounded-full hover:bg-grayScale-50 transition-colors text-grayScale-400"
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,15 @@ import { Input } from "../../../../components/ui/input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadImageFile } from "../../../../api/files.api";
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
import { PublishStatusField } from "./PublishStatusField";
|
|
||||||
import type { PracticePublishStatus } from "../../../../types/course.types";
|
|
||||||
|
|
||||||
interface ContextStepProps {
|
interface ContextStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
setFormData: (data: any) => void;
|
setFormData: (data: any) => void;
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
navigate: (path: string) => void;
|
onCancel: () => void;
|
||||||
level: string;
|
/** Lesson-linked practice: no title, story description, or story image on step 1. */
|
||||||
|
isLessonPractice?: boolean;
|
||||||
|
lessonTitle?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,8 +24,9 @@ export function ContextStep({
|
||||||
formData,
|
formData,
|
||||||
setFormData,
|
setFormData,
|
||||||
nextStep,
|
nextStep,
|
||||||
navigate,
|
onCancel,
|
||||||
level,
|
isLessonPractice = false,
|
||||||
|
lessonTitle = null,
|
||||||
}: ContextStepProps) {
|
}: ContextStepProps) {
|
||||||
const storyFileRef = useRef<HTMLInputElement>(null);
|
const storyFileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploadingStory, setUploadingStory] = useState(false);
|
const [uploadingStory, setUploadingStory] = useState(false);
|
||||||
|
|
@ -48,18 +49,31 @@ export function ContextStep({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canContinue =
|
const canContinue = isLessonPractice
|
||||||
Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
|
? true
|
||||||
|
: Boolean(formData.title?.trim()) && Boolean(formData.description?.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
<Card className="overflow-hidden border-grayScale-300 rounded-2xl bg-white animate-in fade-in duration-500">
|
||||||
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
<div className="border-b border-grayScale-50 px-8 pt-8 pb-4">
|
||||||
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
<h2 className="text-xl font-bold text-grayScale-900 leading-none">
|
||||||
Practice details
|
{isLessonPractice ? "Practice options" : "Practice details"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-grayScale-600 text-base mt-3">
|
<p className="text-grayScale-600 text-base mt-3">
|
||||||
Title, story, optional image, shuffle, and quick tips match the create
|
{isLessonPractice ? (
|
||||||
practice and question set APIs.
|
<>
|
||||||
|
This practice is linked to{" "}
|
||||||
|
<span className="font-medium text-grayScale-800">
|
||||||
|
{lessonTitle?.trim() || "the selected lesson"}
|
||||||
|
</span>
|
||||||
|
. Set optional quick tips and question order below.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Title, story, optional image, shuffle, and quick tips match the create
|
||||||
|
practice and question set APIs.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -76,34 +90,38 @@ export function ContextStep({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 p-10">
|
<div className="space-y-8 p-10">
|
||||||
<div className="space-y-2">
|
{!isLessonPractice ? (
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<>
|
||||||
Practice title <span className="text-red-500">*</span>
|
<div className="space-y-2">
|
||||||
</label>
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
<Input
|
Practice title <span className="text-red-500">*</span>
|
||||||
value={formData.title ?? ""}
|
</label>
|
||||||
onChange={(e) =>
|
<Input
|
||||||
setFormData({ ...formData, title: e.target.value })
|
value={formData.title ?? ""}
|
||||||
}
|
onChange={(e) =>
|
||||||
placeholder="e.g. Lesson 12 conversation drill"
|
setFormData({ ...formData, title: e.target.value })
|
||||||
className="h-11 rounded-xl border-grayScale-200"
|
}
|
||||||
/>
|
placeholder="e.g. Module conversation drill"
|
||||||
</div>
|
className="h-11 rounded-xl border-grayScale-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
Story description <span className="text-red-500">*</span>
|
Story description <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.description ?? ""}
|
value={formData.description ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, description: e.target.value })
|
setFormData({ ...formData, description: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Short scenario for learners…"
|
placeholder="Short scenario for learners…"
|
||||||
className="min-h-[120px] rounded-xl border-grayScale-200"
|
className="min-h-[120px] rounded-xl border-grayScale-200"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
|
|
@ -120,41 +138,43 @@ export function ContextStep({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{!isLessonPractice ? (
|
||||||
<label className="text-sm font-medium text-grayScale-700">
|
<div className="space-y-2">
|
||||||
Story image <span className="text-grayScale-400">(optional)</span>
|
<label className="text-sm font-medium text-grayScale-700">
|
||||||
</label>
|
Story image <span className="text-grayScale-400">(optional)</span>
|
||||||
<Input
|
</label>
|
||||||
value={formData.storyImageUrl ?? ""}
|
<Input
|
||||||
onChange={(e) =>
|
value={formData.storyImageUrl ?? ""}
|
||||||
setFormData({ ...formData, storyImageUrl: e.target.value })
|
onChange={(e) =>
|
||||||
}
|
setFormData({ ...formData, storyImageUrl: e.target.value })
|
||||||
placeholder="https://… or upload"
|
}
|
||||||
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
|
placeholder="https://… or upload"
|
||||||
/>
|
className="h-11 rounded-xl border-grayScale-200 font-mono text-[13px]"
|
||||||
<input
|
/>
|
||||||
ref={storyFileRef}
|
<input
|
||||||
type="file"
|
ref={storyFileRef}
|
||||||
accept="image/*"
|
type="file"
|
||||||
className="hidden"
|
accept="image/*"
|
||||||
onChange={handleStoryImageFile}
|
className="hidden"
|
||||||
/>
|
onChange={handleStoryImageFile}
|
||||||
<Button
|
/>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="outline"
|
||||||
disabled={uploadingStory}
|
size="sm"
|
||||||
onClick={() => storyFileRef.current?.click()}
|
disabled={uploadingStory}
|
||||||
className="gap-2"
|
onClick={() => storyFileRef.current?.click()}
|
||||||
>
|
className="gap-2"
|
||||||
{uploadingStory ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{uploadingStory ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Upload className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<Upload className="h-4 w-4" />
|
||||||
Upload image
|
)}
|
||||||
</Button>
|
Upload image
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
|
<label className="flex cursor-pointer items-center gap-3 text-sm text-grayScale-700">
|
||||||
<input
|
<input
|
||||||
|
|
@ -170,23 +190,13 @@ export function ContextStep({
|
||||||
/>
|
/>
|
||||||
<span>Shuffle questions in the set</span>
|
<span>Shuffle questions in the set</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<PublishStatusField
|
|
||||||
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
|
||||||
onChange={(publishStatus) =>
|
|
||||||
setFormData({ ...formData, publishStatus })
|
|
||||||
}
|
|
||||||
disabled={uploadingStory}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
<div className="flex items-center justify-between border-t border-grayScale-100 bg-[#F8FAFC] p-4 px-12">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
className="text-[14px] font-bold text-grayScale-500 transition-colors hover:text-grayScale-700"
|
||||||
onClick={() =>
|
onClick={onCancel}
|
||||||
navigate(`/new-content/learn-english/${level}/courses`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -196,7 +206,7 @@ export function ContextStep({
|
||||||
disabled={!canContinue}
|
disabled={!canContinue}
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
|
className="h-10 px-10 rounded-[6px] bg-brand-500 text-[14px] font-bold text-white transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: Questions <ArrowRight className="h-5 w-5" />
|
Next: Persona <ArrowRight className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Check, ArrowRight } from "lucide-react";
|
import { Check, ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -6,9 +6,13 @@ import {
|
||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from "../../../../components/ui/avatar";
|
} from "../../../../components/ui/avatar";
|
||||||
import { cn } from "../../../../lib/utils";
|
import { cn } from "../../../../lib/utils";
|
||||||
import { PERSONAS } from "./constants";
|
import type { PersonaCardModel } from "../../../../lib/personaDisplay";
|
||||||
|
|
||||||
interface PersonaStepProps {
|
interface PersonaStepProps {
|
||||||
|
personas: PersonaCardModel[];
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
selectedPersona: string | null;
|
selectedPersona: string | null;
|
||||||
setSelectedPersona: (id: string) => void;
|
setSelectedPersona: (id: string) => void;
|
||||||
nextStep: () => void;
|
nextStep: () => void;
|
||||||
|
|
@ -16,6 +20,10 @@ interface PersonaStepProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonaStep({
|
export function PersonaStep({
|
||||||
|
personas,
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
onRetry,
|
||||||
selectedPersona,
|
selectedPersona,
|
||||||
setSelectedPersona,
|
setSelectedPersona,
|
||||||
nextStep,
|
nextStep,
|
||||||
|
|
@ -25,66 +33,109 @@ export function PersonaStep({
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1 px-2">
|
<div className="space-y-1 px-2">
|
||||||
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
<h2 className="text-2xl font-extrabold text-grayScale-700">
|
||||||
Select Personas
|
Select Persona
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-grayScale-400 text-lg">
|
<p className="text-lg text-grayScale-400">
|
||||||
Choose the characters that will participate in this practice scenario.
|
Choose the character that will guide this practice scenario.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-4">
|
|
||||||
{PERSONAS.map((persona) => {
|
{loading ? (
|
||||||
const isSelected = selectedPersona === persona.id;
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
return (
|
<Loader2 className="h-10 w-10 animate-spin text-brand-500" />
|
||||||
<div
|
<p className="mt-4 text-sm font-medium text-grayScale-500">
|
||||||
key={persona.id}
|
Loading personas…
|
||||||
onClick={() => setSelectedPersona(persona.id)}
|
</p>
|
||||||
className={cn(
|
</div>
|
||||||
"group relative w-[260px] cursor-pointer rounded-2xl border-2 bg-white p-6 transition-all duration-300",
|
) : error ? (
|
||||||
isSelected
|
<div className="rounded-xl border border-red-200 bg-red-50 px-6 py-8 text-center">
|
||||||
? "border-brand-500"
|
<p className="text-sm font-medium text-red-700">{error}</p>
|
||||||
: "border-grayScale-100 hover:border-brand-200",
|
{onRetry ? (
|
||||||
)}
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={onRetry}
|
||||||
>
|
>
|
||||||
{/* Top-right checkmark badge */}
|
Try again
|
||||||
{isSelected && (
|
</Button>
|
||||||
<div className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white z-10">
|
) : null}
|
||||||
<Check className="h-4 w-4 stroke-[3]" />
|
</div>
|
||||||
|
) : personas.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-12 text-center">
|
||||||
|
<p className="text-sm font-medium text-grayScale-600">
|
||||||
|
No active personas available.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-grayScale-400">
|
||||||
|
Add personas in the admin panel, then return here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{personas.map((persona) => {
|
||||||
|
const isSelected = selectedPersona === persona.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={persona.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPersona(persona.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative w-full cursor-pointer rounded-2xl border-2 bg-white p-6 text-left transition-all duration-300",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-500 shadow-md shadow-brand-100/50"
|
||||||
|
: "border-grayScale-100 hover:border-brand-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute right-2.5 top-2.5 z-10 grid h-6 w-6 place-items-center rounded-full bg-brand-500 text-white">
|
||||||
|
<Check className="h-4 w-4 stroke-[3]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-full p-[3px] transition-all duration-300",
|
||||||
|
isSelected ? "bg-brand-500" : "bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="h-24 w-24 border-2 border-white">
|
||||||
|
<AvatarImage src={persona.avatar} alt={persona.name} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{persona.name.substring(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<span className="block text-lg font-bold text-grayScale-700">
|
||||||
|
{persona.name}
|
||||||
|
</span>
|
||||||
|
{persona.description ? (
|
||||||
|
<span className="block text-xs leading-relaxed text-grayScale-500 line-clamp-3">
|
||||||
|
{persona.description}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</button>
|
||||||
<div className="flex flex-col items-center gap-4">
|
);
|
||||||
{/* Avatar with conditional purple ring */}
|
})}
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
)}
|
||||||
"rounded-full p-[3px] transition-all duration-300",
|
|
||||||
isSelected ? "bg-brand-500" : "bg-transparent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Avatar className="h-24 w-24 border-2 border-white">
|
|
||||||
<AvatarImage src={persona.avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{persona.name.substring(0, 2)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-grayScale-700">
|
|
||||||
{persona.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between pt-8">
|
<div className="flex items-center justify-between pt-8">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={prevStep}
|
onClick={prevStep}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
|
className="h-10 w-20 rounded-[6px] border-grayScale-200 text-grayScale-600"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 hover:bg-brand-600 shadow-md shadow-brand-500/20"
|
disabled={!selectedPersona || loading || personas.length === 0}
|
||||||
|
className="h-10 rounded-[6px] bg-brand-500 px-8 shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Edit, Info, Loader2, Play, Rocket } from "lucide-react";
|
||||||
|
import { Button } from "../../../../components/ui/button";
|
||||||
|
import { Card } from "../../../../components/ui/card";
|
||||||
|
import { cn } from "../../../../lib/utils";
|
||||||
|
|
||||||
|
export type PracticeReviewQuestion = {
|
||||||
|
id: string;
|
||||||
|
questionText: string;
|
||||||
|
voicePrompt: string;
|
||||||
|
sampleAnswerVoicePrompt: string;
|
||||||
|
tips?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PracticeReviewMetadataItem = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PracticeSequentialReviewProps = {
|
||||||
|
practiceTitle: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
|
thumbnailKind?: "image" | "video" | "vimeo" | "gradient";
|
||||||
|
persona?: { name: string; avatar: string } | null;
|
||||||
|
metadata?: PracticeReviewMetadataItem[];
|
||||||
|
parentLink?: string | null;
|
||||||
|
guidanceText: string;
|
||||||
|
questions: PracticeReviewQuestion[];
|
||||||
|
saving?: boolean;
|
||||||
|
saveError?: string | null;
|
||||||
|
canPublish?: boolean;
|
||||||
|
showMissingParentWarning?: boolean;
|
||||||
|
onEditContext?: () => void;
|
||||||
|
onEditQuestions?: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
sectionTitle?: string;
|
||||||
|
sectionSubtitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function audioFileLabel(url: string, fallback: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return fallback;
|
||||||
|
try {
|
||||||
|
const path = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
|
||||||
|
if (path) return decodeURIComponent(path);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const seg = trimmed.split("/").filter(Boolean).pop();
|
||||||
|
return seg && seg.length < 64 ? seg : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewAudioPlayer({
|
||||||
|
src,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const fileName = audioFileLabel(src, label);
|
||||||
|
|
||||||
|
if (!src.trim()) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs italic text-grayScale-400">No audio URL provided</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border border-brand-100 bg-brand-50/40 px-2 py-1.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand-500 text-white shadow-sm transition-colors hover:bg-brand-600"
|
||||||
|
aria-label={`Play ${fileName}`}
|
||||||
|
onClick={() => {
|
||||||
|
const audio = new Audio(src);
|
||||||
|
setPlaying(true);
|
||||||
|
void audio.play().finally(() => setPlaying(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play
|
||||||
|
className={cn("h-3.5 w-3.5", playing && "opacity-80")}
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-6 flex-1 items-end gap-0.5 px-1 opacity-70"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{[3, 5, 4, 7, 5, 8, 4, 6, 5, 4, 6, 4].map((h, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-0.5 shrink-0 rounded-full bg-brand-400"
|
||||||
|
style={{ height: `${h + 4}px` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="max-w-[8rem] truncate text-[11px] font-medium text-brand-700 sm:max-w-[10rem]">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{src}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuestionIndex(index: number): string {
|
||||||
|
return String(index + 1).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PracticeSequentialReview({
|
||||||
|
practiceTitle,
|
||||||
|
thumbnailUrl,
|
||||||
|
thumbnailKind = "gradient",
|
||||||
|
persona = null,
|
||||||
|
metadata = [],
|
||||||
|
parentLink = null,
|
||||||
|
guidanceText,
|
||||||
|
questions,
|
||||||
|
saving = false,
|
||||||
|
saveError = null,
|
||||||
|
canPublish = true,
|
||||||
|
showMissingParentWarning = false,
|
||||||
|
onEditContext,
|
||||||
|
onEditQuestions,
|
||||||
|
onBack,
|
||||||
|
onSaveDraft,
|
||||||
|
onPublish,
|
||||||
|
sectionTitle = "Create Practice Questions",
|
||||||
|
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
|
||||||
|
}: PracticeSequentialReviewProps) {
|
||||||
|
const filledQuestions = questions.filter((q) => q.questionText.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{sectionTitle ? (
|
||||||
|
<div className="space-y-1 px-0.5">
|
||||||
|
<h2 className="text-xl font-bold tracking-tight text-grayScale-900 sm:text-2xl">
|
||||||
|
{sectionTitle}
|
||||||
|
</h2>
|
||||||
|
{sectionSubtitle ? (
|
||||||
|
<p className="text-sm text-grayScale-500 sm:text-[15px]">
|
||||||
|
{sectionSubtitle}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showMissingParentWarning && !canPublish ? (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||||
|
<p className="font-semibold">Missing parent for the API</p>
|
||||||
|
<p className="mt-1 text-amber-900/90">
|
||||||
|
Open Add Practice from a course, module, or lesson so parent IDs are
|
||||||
|
in the URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">Basic Information</h3>
|
||||||
|
{onEditContext ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditContext}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start">
|
||||||
|
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner sm:h-20 sm:w-24">
|
||||||
|
{thumbnailKind === "video" && thumbnailUrl ? (
|
||||||
|
<video
|
||||||
|
src={thumbnailUrl}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : thumbnailKind === "vimeo" ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-grayScale-200 to-grayScale-300">
|
||||||
|
<Play className="h-6 w-6 text-brand-500" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
) : thumbnailUrl?.trim() ? (
|
||||||
|
<img
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : persona?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={persona.avatar}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-[#E0F2FE] to-[#BFDBFE]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<h4 className="text-lg font-bold leading-tight text-grayScale-900 sm:text-xl">
|
||||||
|
{practiceTitle.trim() || "Untitled Practice"}
|
||||||
|
</h4>
|
||||||
|
{metadata.length > 0 ? (
|
||||||
|
<dl className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||||||
|
{metadata.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<dt className="inline text-grayScale-900">{item.label}: </dt>
|
||||||
|
<dd className="inline font-medium text-brand-600">
|
||||||
|
{item.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : parentLink ? (
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
<span className="font-medium text-grayScale-800">Link:</span>{" "}
|
||||||
|
{parentLink}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-center gap-2 sm:items-end">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Persona
|
||||||
|
</span>
|
||||||
|
{persona ? (
|
||||||
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
|
<div className="h-12 w-12 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
|
||||||
|
<img
|
||||||
|
src={persona.avatar}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-grayScale-900">
|
||||||
|
{persona.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-grayScale-400">None selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-3 px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-grayScale-900">
|
||||||
|
Tips / Guidance
|
||||||
|
</span>
|
||||||
|
<Info className="h-4 w-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-grayScale-200 bg-white px-5 py-4 shadow-sm">
|
||||||
|
<p className="text-sm leading-relaxed text-grayScale-600">
|
||||||
|
{guidanceText.trim() || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm">
|
||||||
|
<div className="grid md:grid-cols-2 md:divide-x md:divide-grayScale-100">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-2.5 border-b border-grayScale-100 px-6 py-4">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">Questions</h3>
|
||||||
|
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
|
||||||
|
{filledQuestions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
|
||||||
|
{filledQuestions.map((question, index) => (
|
||||||
|
<div key={question.id} className="space-y-3">
|
||||||
|
<span className="text-sm font-bold text-grayScale-400">
|
||||||
|
{formatQuestionIndex(index)}
|
||||||
|
</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Text prompt
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-grayScale-800">
|
||||||
|
{question.questionText.trim() || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{question.voicePrompt.trim() ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Voice prompt
|
||||||
|
</p>
|
||||||
|
<ReviewAudioPlayer
|
||||||
|
src={question.voicePrompt}
|
||||||
|
label={`prompt_q${index + 1}.mp3`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col border-t border-grayScale-100 md:border-t-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-grayScale-100 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h3 className="font-semibold text-grayScale-900">Answers</h3>
|
||||||
|
<span className="flex h-6 min-w-6 items-center justify-center rounded-full bg-grayScale-100 px-2 text-xs font-semibold text-grayScale-500">
|
||||||
|
{filledQuestions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onEditQuestions ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditQuestions}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
|
||||||
|
{filledQuestions.map((question, index) => (
|
||||||
|
<div key={question.id} className="space-y-3">
|
||||||
|
<span className="text-sm font-bold text-grayScale-400">
|
||||||
|
{formatQuestionIndex(index)}
|
||||||
|
</span>
|
||||||
|
{question.sampleAnswerVoicePrompt.trim() ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-500">
|
||||||
|
Voice prompt
|
||||||
|
</p>
|
||||||
|
<ReviewAudioPlayer
|
||||||
|
src={question.sampleAnswerVoicePrompt}
|
||||||
|
label={`answer_q${index + 1}.mp3`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-grayScale-400">—</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{saveError ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-red-600">{saveError}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col-reverse items-stretch justify-between gap-3 pt-2 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
disabled={saving || !canPublish}
|
||||||
|
className="h-10 rounded-[6px] border-brand-500 bg-white px-8 text-sm font-bold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save as Draft"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={saving || !canPublish}
|
||||||
|
className="h-10 gap-2 rounded-[6px] bg-brand-500 px-8 text-sm font-bold text-white shadow-md shadow-brand-500/20 hover:bg-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{saving ? "Publishing…" : "Publish Now"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,37 @@
|
||||||
import { Rocket, Info, Loader2 } from "lucide-react";
|
import { useMemo } from "react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
|
||||||
import { Card } from "../../../../components/ui/card";
|
|
||||||
import { Input } from "../../../../components/ui/input";
|
|
||||||
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
|
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
|
||||||
|
import { personaFromId } from "./constants";
|
||||||
|
import type { PersonaCardModel } from "../../../../lib/personaDisplay";
|
||||||
|
import { mapFormQuestionsForPracticeReview } from "./mapQuestionsForPracticeReview";
|
||||||
import {
|
import {
|
||||||
definitionUsesDynamicPayload,
|
PracticeSequentialReview,
|
||||||
legacyQuestionTypeFromDefinition,
|
type PracticeReviewMetadataItem,
|
||||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
} from "./PracticeSequentialReview";
|
||||||
import { PublishStatusField } from "./PublishStatusField";
|
|
||||||
import type { PracticePublishStatus } from "../../../../types/course.types";
|
|
||||||
|
|
||||||
interface ReviewStepProps {
|
interface ReviewStepProps {
|
||||||
formData: any;
|
formData: {
|
||||||
setFormData: (data: any) => void;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
storyImageUrl?: string;
|
||||||
|
tips?: string;
|
||||||
|
shuffleQuestions?: boolean;
|
||||||
|
questions: {
|
||||||
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
dynamicFieldValues?: Record<string, string>;
|
||||||
|
questionTypeDefinitionId?: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
selectedPersona?: string | null;
|
||||||
|
personas?: PersonaCardModel[];
|
||||||
|
isLessonPractice?: boolean;
|
||||||
|
lessonTitle?: string | null;
|
||||||
|
programLabel?: string | null;
|
||||||
|
courseLabel?: string | null;
|
||||||
|
moduleLabel?: string | null;
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
|
onEditContext?: () => void;
|
||||||
|
onEditQuestions?: () => void;
|
||||||
parentSummary: string | null;
|
parentSummary: string | null;
|
||||||
typeDefinitions: QuestionTypeDefinition[];
|
typeDefinitions: QuestionTypeDefinition[];
|
||||||
canPublish: boolean;
|
canPublish: boolean;
|
||||||
|
|
@ -24,8 +42,16 @@ interface ReviewStepProps {
|
||||||
|
|
||||||
export function ReviewStep({
|
export function ReviewStep({
|
||||||
formData,
|
formData,
|
||||||
setFormData,
|
selectedPersona = null,
|
||||||
|
personas = [],
|
||||||
|
isLessonPractice = false,
|
||||||
|
lessonTitle = null,
|
||||||
|
programLabel = null,
|
||||||
|
courseLabel = null,
|
||||||
|
moduleLabel = null,
|
||||||
prevStep,
|
prevStep,
|
||||||
|
onEditContext,
|
||||||
|
onEditQuestions,
|
||||||
parentSummary,
|
parentSummary,
|
||||||
typeDefinitions,
|
typeDefinitions,
|
||||||
canPublish,
|
canPublish,
|
||||||
|
|
@ -33,292 +59,61 @@ export function ReviewStep({
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
onPublish,
|
onPublish,
|
||||||
}: ReviewStepProps) {
|
}: ReviewStepProps) {
|
||||||
return (
|
const persona = personaFromId(selectedPersona, personas);
|
||||||
<div className="space-y-10 animate-in fade-in duration-700">
|
|
||||||
<div className="flex items-center justify-between px-2">
|
|
||||||
<h2 className="text-2xl font-bold text-grayScale-900 tracking-tight">
|
|
||||||
Review
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canPublish && (
|
const reviewQuestions = useMemo(
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
() => mapFormQuestionsForPracticeReview(formData.questions, typeDefinitions),
|
||||||
<p className="font-semibold">Missing parent for the API</p>
|
[formData.questions, typeDefinitions],
|
||||||
<p className="mt-1 text-amber-900/90">
|
|
||||||
Open Add Practice from a course, module, or lesson so parent IDs are
|
|
||||||
in the URL.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className="overflow-hidden border border-grayScale-200 rounded-2xl bg-white">
|
|
||||||
<div className="border-b border-grayScale-50 px-5 py-4">
|
|
||||||
<h3 className="text-[17px] font-extrabold text-grayScale-900">
|
|
||||||
Practice
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div className="w-full border-t border-grayScale-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 sm:p-8">
|
|
||||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
|
||||||
<div className="h-[70px] w-[85px] shrink-0 overflow-hidden rounded-xl bg-grayScale-100 shadow-inner">
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
formData.storyImageUrl?.trim() ||
|
|
||||||
"https://images.unsplash.com/photo-1558403194-611308249627?auto=format&fit=crop&q=80&w=200"
|
|
||||||
}
|
|
||||||
alt="Story"
|
|
||||||
className="h-full w-full object-cover opacity-80"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
|
||||||
<h4 className="text-xl font-bold leading-tight text-grayScale-900">
|
|
||||||
{formData.title || "Untitled"}
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-grayScale-600">
|
|
||||||
{parentSummary ? (
|
|
||||||
<span>
|
|
||||||
<span className="font-medium text-grayScale-800">Link:</span>{" "}
|
|
||||||
{parentSummary}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{formData.shuffleQuestions ? (
|
|
||||||
<p className="text-xs text-grayScale-500">Shuffle questions: on</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-4 px-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-[12px] font-bold uppercase tracking-widest text-grayScale-900">
|
|
||||||
Quick tips
|
|
||||||
</label>
|
|
||||||
<Info className="h-4 w-4 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-[#E2E8F0] bg-white px-5 py-4 shadow-sm">
|
|
||||||
<p className="text-[14px] font-medium leading-relaxed text-grayScale-600">
|
|
||||||
{formData.tips?.trim() || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="px-2 text-lg font-bold text-grayScale-900">Questions</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{formData.questions.map((q: any, i: number) => (
|
|
||||||
<QuestionReviewBlock
|
|
||||||
key={q.id}
|
|
||||||
q={q}
|
|
||||||
index={i}
|
|
||||||
typeDefinitions={typeDefinitions}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PublishStatusField
|
|
||||||
className="px-2"
|
|
||||||
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
|
||||||
onChange={(publishStatus) => setFormData({ ...formData, publishStatus })}
|
|
||||||
disabled={submitting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-12">
|
|
||||||
<Button
|
|
||||||
onClick={prevStep}
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 rounded-[6px] border-grayScale-200 bg-white px-10 text-sm font-bold text-grayScale-600 shadow-sm transition-all hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={submitting || !canPublish}
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({ ...formData, publishStatus: "DRAFT" });
|
|
||||||
onSaveDraft();
|
|
||||||
}}
|
|
||||||
className="h-10 rounded-[6px] border-grayScale-100 bg-white px-8 text-sm font-bold text-grayScale-600 shadow-sm hover:bg-grayScale-50"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Save as Draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={submitting || !canPublish}
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({ ...formData, publishStatus: "PUBLISHED" });
|
|
||||||
onPublish();
|
|
||||||
}}
|
|
||||||
className="h-10 gap-3 rounded-[6px] bg-brand-500 px-10 text-sm font-bold text-white shadow-xl shadow-brand-500/20 transition-all hover:bg-brand-600 active:scale-95 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Rocket className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Publish Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function QuestionReviewBlock({
|
const metadata = useMemo((): PracticeReviewMetadataItem[] => {
|
||||||
q,
|
const items: PracticeReviewMetadataItem[] = [];
|
||||||
index,
|
if (programLabel?.trim()) {
|
||||||
typeDefinitions,
|
items.push({ label: "Program", value: programLabel.trim() });
|
||||||
}: {
|
|
||||||
q: any;
|
|
||||||
index: number;
|
|
||||||
typeDefinitions: QuestionTypeDefinition[];
|
|
||||||
}) {
|
|
||||||
const def = typeDefinitions.find((d) => d.id === q.questionTypeDefinitionId);
|
|
||||||
const badge =
|
|
||||||
def != null
|
|
||||||
? `${def.display_name}${def.is_system ? "" : ` · ${def.key}`}`
|
|
||||||
: q.questionTypeDefinitionId != null
|
|
||||||
? `Type #${q.questionTypeDefinitionId}`
|
|
||||||
: "No type selected";
|
|
||||||
|
|
||||||
const isDynamic = def != null && definitionUsesDynamicPayload(def);
|
|
||||||
const legacy = def != null ? legacyQuestionTypeFromDefinition(def) : null;
|
|
||||||
|
|
||||||
const schemaRows: { key: string; label: string; value: string }[] = [];
|
|
||||||
if (isDynamic && def) {
|
|
||||||
const vals = (q.dynamicFieldValues ?? {}) as Record<string, string>;
|
|
||||||
for (const r of def.stimulus_schema) {
|
|
||||||
const k = `stimulus:${r.id}`;
|
|
||||||
schemaRows.push({
|
|
||||||
key: k,
|
|
||||||
label: r.label?.trim() || r.kind,
|
|
||||||
value: vals[k] ?? "",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (const r of def.response_schema) {
|
if (courseLabel?.trim()) {
|
||||||
const k = `response:${r.id}`;
|
items.push({ label: "Course", value: courseLabel.trim() });
|
||||||
schemaRows.push({
|
|
||||||
key: k,
|
|
||||||
label: r.label?.trim() || r.kind,
|
|
||||||
value: vals[k] ?? "",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
if (moduleLabel?.trim()) {
|
||||||
|
items.push({ label: "Module", value: moduleLabel.trim() });
|
||||||
|
}
|
||||||
|
if (isLessonPractice && lessonTitle?.trim()) {
|
||||||
|
items.push({ label: "Lesson", value: lessonTitle.trim() });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [programLabel, courseLabel, moduleLabel, isLessonPractice, lessonTitle]);
|
||||||
|
|
||||||
|
const practiceTitle = isLessonPractice
|
||||||
|
? lessonTitle?.trim() || parentSummary || "Lesson practice"
|
||||||
|
: formData.title?.trim() || "Untitled Practice";
|
||||||
|
|
||||||
|
const guidanceText =
|
||||||
|
formData.tips?.trim() ||
|
||||||
|
(!isLessonPractice ? formData.description?.trim() : "") ||
|
||||||
|
"—";
|
||||||
|
|
||||||
|
const thumbnailUrl = isLessonPractice
|
||||||
|
? null
|
||||||
|
: formData.storyImageUrl?.trim() || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden rounded-2xl border-grayScale-50 bg-white shadow-soft">
|
<PracticeSequentialReview
|
||||||
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
|
practiceTitle={practiceTitle}
|
||||||
<div className="space-y-4 px-5 pb-6 pt-4 pl-7">
|
thumbnailUrl={thumbnailUrl}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-grayScale-50 pb-3">
|
thumbnailKind={thumbnailUrl ? "image" : "gradient"}
|
||||||
<span className="text-base font-bold text-grayScale-500">
|
persona={persona ?? null}
|
||||||
Question {index + 1}
|
metadata={metadata}
|
||||||
</span>
|
parentLink={metadata.length === 0 ? parentSummary : null}
|
||||||
<span className="rounded-md bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">
|
guidanceText={guidanceText}
|
||||||
{badge}
|
questions={reviewQuestions}
|
||||||
</span>
|
saving={submitting}
|
||||||
</div>
|
canPublish={canPublish}
|
||||||
|
showMissingParentWarning
|
||||||
<div className="space-y-2">
|
onEditContext={onEditContext}
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
onEditQuestions={onEditQuestions}
|
||||||
Question text
|
onBack={prevStep}
|
||||||
</span>
|
onSaveDraft={onSaveDraft}
|
||||||
<Input
|
onPublish={onPublish}
|
||||||
value={q.text}
|
/>
|
||||||
readOnly
|
|
||||||
className="min-h-[52px] rounded-xl border-grayScale-200 bg-white px-4 py-3 text-base font-medium text-grayScale-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDynamic && schemaRows.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
|
||||||
Stimulus and response fields
|
|
||||||
</span>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{schemaRows.map((row) => (
|
|
||||||
<li key={row.key} className="rounded-lg border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
|
|
||||||
<span className="block text-[10px] font-bold uppercase tracking-wide text-grayScale-500">
|
|
||||||
{row.label}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block break-all font-mono text-xs text-grayScale-800">
|
|
||||||
{row.value?.trim() ? row.value : "—"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{legacy === "MCQ" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
|
||||||
Choices
|
|
||||||
</span>
|
|
||||||
<ul className="space-y-1.5 text-sm">
|
|
||||||
{(q.mcqOptions ?? []).map(
|
|
||||||
(opt: { text?: string; isCorrect?: boolean }, j: number) =>
|
|
||||||
opt.text?.trim() ? (
|
|
||||||
<li
|
|
||||||
key={j}
|
|
||||||
className={
|
|
||||||
opt.isCorrect
|
|
||||||
? "font-medium text-green-700"
|
|
||||||
: "text-grayScale-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{opt.isCorrect ? "✓ " : ""}
|
|
||||||
{opt.text}
|
|
||||||
</li>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{legacy === "TRUE_FALSE" && (
|
|
||||||
<p className="text-sm text-grayScale-700">
|
|
||||||
<span className="font-semibold">Correct:</span>{" "}
|
|
||||||
{q.trueFalseCorrect !== false ? "True" : "False"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{legacy === "SHORT_ANSWER" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-grayScale-600">
|
|
||||||
Acceptable answers
|
|
||||||
</span>
|
|
||||||
<ul className="list-disc space-y-1 pl-5 text-sm text-grayScale-700">
|
|
||||||
{(q.shortAnswers ?? [])
|
|
||||||
.filter((s: string) => s?.trim())
|
|
||||||
.map((s: string, j: number) => (
|
|
||||||
<li key={j}>{s}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{def != null && legacy == null && !isDynamic ? (
|
|
||||||
<p className="text-xs text-amber-800">
|
|
||||||
This type has no schema and is not mapped to a classic MCQ / true–false /
|
|
||||||
short-answer form. Publish still sends the best-effort payload from the
|
|
||||||
builder.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,6 @@ import { Input } from "../../../../components/ui/input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { uploadImageFile } from "../../../../api/files.api";
|
import { uploadImageFile } from "../../../../api/files.api";
|
||||||
import { PublishStatusField } from "./PublishStatusField";
|
|
||||||
import type { PracticePublishStatus } from "../../../../types/course.types";
|
|
||||||
|
|
||||||
interface ScenarioStepProps {
|
interface ScenarioStepProps {
|
||||||
formData: any;
|
formData: any;
|
||||||
setFormData: (data: any) => void;
|
setFormData: (data: any) => void;
|
||||||
|
|
@ -160,13 +157,6 @@ export function ScenarioStep({
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PublishStatusField
|
|
||||||
value={(formData.publishStatus ?? "DRAFT") as PracticePublishStatus}
|
|
||||||
onChange={(publishStatus) =>
|
|
||||||
setFormData({ ...formData, publishStatus })
|
|
||||||
}
|
|
||||||
disabled={uploadingBanner}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
|
@ -179,7 +169,7 @@ export function ScenarioStep({
|
||||||
disabled={!canContinue}
|
disabled={!canContinue}
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
|
className="h-10 rounded-[6px] bg-brand-500 px-8 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Next: Questions <ArrowRight className="ml-2 h-4 w-4" />
|
Next: Persona <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,16 @@
|
||||||
export const PERSONAS = [
|
import type { PersonaCardModel } from "../../../../lib/personaDisplay"
|
||||||
{
|
|
||||||
id: "dawit",
|
|
||||||
name: "Dawit",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mahlet",
|
|
||||||
name: "Mahlet",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "amanuel",
|
|
||||||
name: "Amanuel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bethel",
|
|
||||||
name: "Bethel",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "liya",
|
|
||||||
name: "Liya",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "aseffa",
|
|
||||||
name: "Aseffa",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "hana",
|
|
||||||
name: "Hana",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "nahom",
|
|
||||||
name: "Nahom",
|
|
||||||
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"];
|
export const STEPS = ["Context", "Scenario", "Persona", "Questions", "Review"]
|
||||||
|
|
||||||
|
export function personaFromId(
|
||||||
|
selectedPersona: string | null,
|
||||||
|
personas: PersonaCardModel[],
|
||||||
|
): PersonaCardModel | undefined {
|
||||||
|
if (!selectedPersona) return undefined
|
||||||
|
return personas.find((p) => p.id === selectedPersona)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personaIdNumber(selectedPersona: string | null): number | undefined {
|
||||||
|
const n = Number(selectedPersona)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import type { QuestionTypeDefinition } from "../../../../types/questionTypeDefinition.types";
|
||||||
|
import {
|
||||||
|
definitionUsesDynamicPayload,
|
||||||
|
legacyQuestionTypeFromDefinition,
|
||||||
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
|
import type { PracticeReviewQuestion } from "./PracticeSequentialReview";
|
||||||
|
|
||||||
|
function isAudioLikeKind(kind: string): boolean {
|
||||||
|
const k = kind.toLowerCase();
|
||||||
|
return (
|
||||||
|
k.includes("audio") ||
|
||||||
|
k.includes("voice") ||
|
||||||
|
k === "url" ||
|
||||||
|
k === "file" ||
|
||||||
|
k === "media"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstUrlFromSchema(
|
||||||
|
schema: { id: number; kind: string }[],
|
||||||
|
prefix: "stimulus" | "response",
|
||||||
|
values: Record<string, string>,
|
||||||
|
): string {
|
||||||
|
for (const row of schema) {
|
||||||
|
if (!isAudioLikeKind(row.kind)) continue;
|
||||||
|
const v = values[`${prefix}:${row.id}`]?.trim();
|
||||||
|
if (v) return v;
|
||||||
|
}
|
||||||
|
for (const row of schema) {
|
||||||
|
const v = values[`${prefix}:${row.id}`]?.trim();
|
||||||
|
if (v && /^https?:\/\//i.test(v)) return v;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFormQuestionsForPracticeReview(
|
||||||
|
questions: {
|
||||||
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
dynamicFieldValues?: Record<string, string>;
|
||||||
|
questionTypeDefinitionId?: number | null;
|
||||||
|
}[],
|
||||||
|
typeDefinitions: QuestionTypeDefinition[],
|
||||||
|
): PracticeReviewQuestion[] {
|
||||||
|
return questions.map((q) => {
|
||||||
|
const def = typeDefinitions.find(
|
||||||
|
(d) => d.id === q.questionTypeDefinitionId,
|
||||||
|
);
|
||||||
|
const values = q.dynamicFieldValues ?? {};
|
||||||
|
let voicePrompt = "";
|
||||||
|
let sampleAnswerVoicePrompt = "";
|
||||||
|
|
||||||
|
if (def && definitionUsesDynamicPayload(def)) {
|
||||||
|
voicePrompt = firstUrlFromSchema(def.stimulus_schema, "stimulus", values);
|
||||||
|
sampleAnswerVoicePrompt = firstUrlFromSchema(
|
||||||
|
def.response_schema,
|
||||||
|
"response",
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
} else if (def) {
|
||||||
|
const legacy = legacyQuestionTypeFromDefinition(def);
|
||||||
|
const key = def.key.toLowerCase();
|
||||||
|
if (legacy === null && key.includes("audio")) {
|
||||||
|
voicePrompt = Object.entries(values)
|
||||||
|
.filter(([k]) => k.startsWith("stimulus:"))
|
||||||
|
.map(([, v]) => v?.trim())
|
||||||
|
.find(Boolean) ?? "";
|
||||||
|
sampleAnswerVoicePrompt =
|
||||||
|
Object.entries(values)
|
||||||
|
.filter(([k]) => k.startsWith("response:"))
|
||||||
|
.map(([, v]) => v?.trim())
|
||||||
|
.find(Boolean) ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: q.id,
|
||||||
|
questionText: String(q.text ?? "").trim(),
|
||||||
|
voicePrompt,
|
||||||
|
sampleAnswerVoicePrompt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Rocket, Edit2, Link2, Video } from "lucide-react";
|
import { Rocket, Edit2, Link2, Video } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { toast } from "sonner";
|
import type { PracticePublishStatus } from "../../../../types/course.types";
|
||||||
import type { AddLessonFormData } from "../../AddVideoFlow";
|
import type { AddLessonFormData } from "../../AddVideoFlow";
|
||||||
import {
|
import {
|
||||||
applyShortPreviewToEmbedUrl,
|
applyShortPreviewToEmbedUrl,
|
||||||
|
|
@ -15,7 +15,7 @@ import { PreviewLimitedFileVideo } from "../PreviewLimitedFileVideo";
|
||||||
interface ReviewPublishStepProps {
|
interface ReviewPublishStepProps {
|
||||||
formData: AddLessonFormData;
|
formData: AddLessonFormData;
|
||||||
prevStep: () => void;
|
prevStep: () => void;
|
||||||
onPublish: () => void;
|
onCreateLesson: (publishStatus: PracticePublishStatus) => void;
|
||||||
publishing: boolean;
|
publishing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ function truncate(s: string, max: number): string {
|
||||||
export function ReviewPublishStep({
|
export function ReviewPublishStep({
|
||||||
formData,
|
formData,
|
||||||
prevStep,
|
prevStep,
|
||||||
onPublish,
|
onCreateLesson,
|
||||||
publishing,
|
publishing,
|
||||||
}: ReviewPublishStepProps) {
|
}: ReviewPublishStepProps) {
|
||||||
const [thumbBroken, setThumbBroken] = useState(false);
|
const [thumbBroken, setThumbBroken] = useState(false);
|
||||||
|
|
@ -180,6 +180,17 @@ export function ReviewPublishStep({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
|
Sort order
|
||||||
|
</span>
|
||||||
|
<p className="text-[15px] font-medium text-grayScale-900">
|
||||||
|
{formData.sortOrder.trim() !== ""
|
||||||
|
? formData.sortOrder.trim()
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
<span className="text-[11px] font-bold text-grayScale-500 uppercase tracking-widest block">
|
||||||
Description
|
Description
|
||||||
|
|
@ -226,20 +237,18 @@ export function ReviewPublishStep({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
className="h-12 px-8 rounded-[6px] border-grayScale-100 font-bold text-grayScale-600 hover:bg-grayScale-50 transition-all shadow-sm"
|
||||||
disabled={publishing}
|
disabled={publishing}
|
||||||
onClick={() =>
|
onClick={() => onCreateLesson("DRAFT")}
|
||||||
toast.info("Drafts are not supported yet. Use Create lesson.")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Save as draft
|
Save as draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onPublish}
|
onClick={() => onCreateLesson("PUBLISHED")}
|
||||||
disabled={publishing}
|
disabled={publishing}
|
||||||
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
|
className="h-10 px-10 rounded-[6px] bg-brand-500 font-bold text-white shadow-brand-500/20 transition-all flex items-center gap-2.5 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Rocket className="h-4 w-4" />
|
<Rocket className="h-4 w-4" />
|
||||||
{publishing ? "Creating…" : "Create lesson"}
|
{publishing ? "Creating…" : "Publish lesson"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,16 @@ export function VideoDetailStep({
|
||||||
toast.error("Title is required");
|
toast.error("Title is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sortOrderRaw = formData.sortOrder.trim();
|
||||||
|
if (sortOrderRaw === "") {
|
||||||
|
toast.error("Sort order is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sortOrderNum = Number(sortOrderRaw);
|
||||||
|
if (!Number.isInteger(sortOrderNum) || sortOrderNum < 0) {
|
||||||
|
toast.error("Sort order must be a whole number of 0 or greater");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!formData.videoUrl.trim()) {
|
if (!formData.videoUrl.trim()) {
|
||||||
toast.error("Add a video URL or upload a video");
|
toast.error("Add a video URL or upload a video");
|
||||||
return;
|
return;
|
||||||
|
|
@ -141,6 +151,35 @@ export function VideoDetailStep({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label
|
||||||
|
className="text-[14px] font-medium text-grayScale-900 ml-1"
|
||||||
|
htmlFor="lesson-sort-order"
|
||||||
|
>
|
||||||
|
Sort order
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="lesson-sort-order"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder="0"
|
||||||
|
className="h-12 max-w-[200px] rounded-xl border-grayScale-200 bg-white px-6 text-[15px] text-grayScale-800 placeholder:text-grayScale-500 focus:border-brand-500 font-medium transition-all shadow-sm"
|
||||||
|
value={formData.sortOrder}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sortOrder: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-grayScale-500 ml-1">
|
||||||
|
Whole number, 0 or greater. Lower numbers appear first in the
|
||||||
|
module.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
<label className="text-[14px] font-medium text-grayScale-900 ml-1">
|
||||||
Description
|
Description
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ export interface CreateExamPrepCatalogUnitRequest {
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
thumbnail?: string | null
|
thumbnail?: string | null
|
||||||
|
sort_order: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateExamPrepCatalogUnitResponse {
|
export interface CreateExamPrepCatalogUnitResponse {
|
||||||
|
|
@ -329,6 +330,9 @@ export interface ExamPrepModuleLessonItem {
|
||||||
thumbnail?: string | null
|
thumbnail?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
sort_order?: number
|
sort_order?: number
|
||||||
|
/** Total length in seconds when the API provides it. */
|
||||||
|
duration?: number | null
|
||||||
|
duration_seconds?: number | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
@ -338,6 +342,7 @@ export interface CreateExamPrepModuleLessonRequest {
|
||||||
video_url: string
|
video_url: string
|
||||||
thumbnail?: string | null
|
thumbnail?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
publish_status: PracticePublishStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateExamPrepModuleLessonResponse {
|
export interface CreateExamPrepModuleLessonResponse {
|
||||||
|
|
@ -448,6 +453,11 @@ export interface TopLevelModuleLessonItem {
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
description: string
|
description: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
|
publish_status?: PracticePublishStatus | string | null
|
||||||
|
has_practice?: boolean
|
||||||
|
/** Total length in seconds when the API provides it. */
|
||||||
|
duration?: number | null
|
||||||
|
duration_seconds?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -547,6 +557,12 @@ export interface UpdateTopLevelModuleLessonRequest {
|
||||||
video_url: string
|
video_url: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
description: string
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish-only patch: PUT /lessons/:id with { publish_status }. */
|
||||||
|
export interface PublishTopLevelModuleLessonRequest {
|
||||||
|
publish_status: PracticePublishStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body for POST /modules/:moduleId/lessons. */
|
/** Body for POST /modules/:moduleId/lessons. */
|
||||||
|
|
@ -555,6 +571,8 @@ export interface CreateTopLevelModuleLessonRequest {
|
||||||
video_url: string
|
video_url: string
|
||||||
thumbnail: string
|
thumbnail: string
|
||||||
description: string
|
description: string
|
||||||
|
sort_order: number
|
||||||
|
publish_status: PracticePublishStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateTopLevelModuleLessonResponse {
|
export interface CreateTopLevelModuleLessonResponse {
|
||||||
|
|
|
||||||
26
src/types/persona.types.ts
Normal file
26
src/types/persona.types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
export interface PersonaListItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
profile_picture: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPersonasParams {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPersonasResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
personas: PersonaListItem[]
|
||||||
|
total_count: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user