course creation integartion fix

This commit is contained in:
Yared Yemane 2026-03-28 08:36:06 -07:00
parent 7ffbc666b0
commit 4d5d4f0d15
4 changed files with 257 additions and 115 deletions

View File

@ -7,10 +7,12 @@ export interface FileUploadProps extends Omit<React.InputHTMLAttributes<HTMLInpu
accept?: string accept?: string
label?: string label?: string
description?: string description?: string
/** Shorter, row-oriented layout for wide forms / modals */
variant?: "default" | "compact"
} }
export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>( export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
({ className, onFileSelect, accept, label, description, ...props }, ref) => { ({ className, onFileSelect, accept, label, description, variant = "default", ...props }, ref) => {
const [file, setFile] = React.useState<File | null>(null) const [file, setFile] = React.useState<File | null>(null)
const [dragActive, setDragActive] = React.useState(false) const [dragActive, setDragActive] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null) const inputRef = React.useRef<HTMLInputElement>(null)
@ -48,10 +50,15 @@ export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
} }
} }
const isCompact = variant === "compact"
return ( return (
<div <div
className={cn( className={cn(
"relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors", "relative flex rounded-lg border-2 border-dashed transition-colors",
isCompact
? "min-h-0 flex-col items-stretch justify-center sm:flex-row sm:items-center"
: "flex-col items-center justify-center",
dragActive ? "border-brand-500 bg-brand-50" : "border-grayScale-200 bg-grayScale-50", dragActive ? "border-brand-500 bg-brand-50" : "border-grayScale-200 bg-grayScale-50",
className, className,
)} )}
@ -68,27 +75,44 @@ export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
onChange={handleChange} onChange={handleChange}
{...props} {...props}
/> />
<div className="flex flex-col items-center justify-center p-8 text-center"> <div
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-brand-600"> className={cn(
<Upload className="h-8 w-8" /> "flex w-full",
isCompact
? "flex-col gap-3 p-4 text-center sm:flex-row sm:items-center sm:gap-4 sm:p-4 sm:text-left"
: "flex-col items-center justify-center p-8 text-center",
)}
>
<div
className={cn(
"grid shrink-0 place-items-center rounded-full bg-brand-100 text-brand-600",
isCompact ? "mx-auto h-11 w-11 sm:mx-0" : "mb-4 h-16 w-16",
)}
>
<Upload className={isCompact ? "h-5 w-5" : "h-8 w-8"} />
</div> </div>
{file ? ( {file ? (
<> <div className={cn("min-w-0 flex-1", !isCompact && "text-center")}>
<p className="mb-1 text-sm font-medium text-grayScale-900">{file.name}</p> <p className="mb-1 text-sm font-medium text-grayScale-900 break-all">{file.name}</p>
<p className="text-xs text-grayScale-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p> <p className="text-xs text-grayScale-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</> </div>
) : ( ) : (
<> <>
<p className="mb-1 text-sm font-medium text-grayScale-900"> <div className={cn("min-w-0 flex-1 space-y-1", isCompact && "sm:pr-2")}>
<p className="text-sm font-medium text-grayScale-900">
{label || "Drag & Drop Video Here"} {label || "Drag & Drop Video Here"}
</p> </p>
<p className="mb-4 text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
{description || "or click to browse files"} {description || "or click to browse files"}
</p> </p>
</div>
<button <button
type="button" type="button"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
className="rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white hover:bg-brand-600" className={cn(
"shrink-0 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-brand-600",
isCompact ? "mx-auto w-full max-w-[200px] sm:mx-0 sm:w-auto" : "mt-1",
)}
> >
Browse Files Browse Files
</button> </button>

View File

@ -34,7 +34,7 @@ export function AppLayout() {
}`} }`}
> >
<Topbar onSidebarToggle={handleSidebarToggle} /> <Topbar onSidebarToggle={handleSidebarToggle} />
<main className="min-w-0 flex-1 overflow-y-auto px-4 pb-8 pt-4 lg:px-6"> <main className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<Outlet /> <Outlet />
</main> </main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6"> <footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">

View File

@ -35,7 +35,9 @@ import {
addSubCoursePrerequisite, addSubCoursePrerequisite,
removeSubCoursePrerequisite, removeSubCoursePrerequisite,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { FileUpload } from "../../components/ui/file-upload";
import type { import type {
SubCourse, SubCourse,
Course, Course,
@ -43,6 +45,7 @@ import type {
SubCoursePrerequisite, SubCoursePrerequisite,
} from "../../types/course.types"; } from "../../types/course.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon"; import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { toast } from "sonner";
export function SubCoursesPage() { export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{ const { categoryId, courseId } = useParams<{
@ -72,7 +75,11 @@ export function SubCoursesPage() {
); );
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [level, setLevel] = useState(""); const [level, setLevel] = useState("BEGINNER");
const [subLevel, setSubLevel] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState("");
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [displayOrder, setDisplayOrder] = useState("1");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
@ -222,10 +229,19 @@ export function SubCoursesPage() {
} }
}; };
const nextSubCourseDisplayOrder = () =>
subCourses.length === 0
? 1
: Math.max(0, ...subCourses.map((s) => s.display_order ?? 0)) + 1;
const handleAddSubCourse = () => { const handleAddSubCourse = () => {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setLevel(""); setLevel("BEGINNER");
setSubLevel("");
setThumbnailUrl("");
setThumbnailFile(null);
setDisplayOrder(String(nextSubCourseDisplayOrder()));
setSaveError(null); setSaveError(null);
setShowAddModal(true); setShowAddModal(true);
}; };
@ -235,20 +251,45 @@ export function SubCoursesPage() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
let thumbnail = thumbnailUrl.trim();
if (thumbnailFile) {
const uploadRes = await uploadImageFile(thumbnailFile);
const uploadedUrl = uploadRes.data?.data?.url?.trim();
if (!uploadedUrl) throw new Error("Missing uploaded image url");
thumbnail = uploadedUrl;
}
const parsedOrder = parseInt(displayOrder, 10);
const display_order = Number.isFinite(parsedOrder) && parsedOrder >= 0
? parsedOrder
: nextSubCourseDisplayOrder();
await createSubCourse({ await createSubCourse({
course_id: Number(courseId), course_id: Number(courseId),
title, title: title.trim(),
description, description: description.trim(),
level, thumbnail,
display_order,
level: level.trim() || "BEGINNER",
sub_level: subLevel.trim(),
}); });
setShowAddModal(false); setShowAddModal(false);
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setLevel(""); setLevel("BEGINNER");
setSubLevel("");
setThumbnailUrl("");
setThumbnailFile(null);
setDisplayOrder("1");
await fetchSubCourses(); await fetchSubCourses();
} catch (err) { toast.success("Course created successfully");
} catch (err: unknown) {
console.error("Failed to create sub-course:", err); console.error("Failed to create sub-course:", err);
setSaveError("Failed to create course"); const msg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to create course";
setSaveError(msg);
toast.error(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -649,8 +690,8 @@ export function SubCoursesPage() {
{/* Delete Modal */} {/* Delete Modal */}
{showDeleteModal && subCourseToDelete && ( {showDeleteModal && subCourseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-black/40 p-3 backdrop-blur-sm sm:p-6">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl"> <div className="my-auto w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> <div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<h2 className="text-lg font-semibold text-grayScale-700"> <h2 className="text-lg font-semibold text-grayScale-700">
Delete Course Delete Course
@ -697,24 +738,30 @@ export function SubCoursesPage() {
</div> </div>
)} )}
{/* Add Sub-course Modal */} {/* Add Sub-course Modal — POST /course-management/sub-courses */}
{showAddModal && ( {showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-black/40 p-3 backdrop-blur-sm sm:p-6">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl"> <div
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> role="dialog"
<h2 className="text-lg font-semibold text-grayScale-700"> aria-modal="true"
className="my-auto w-full max-w-4xl flex-shrink-0 rounded-2xl bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3 sm:px-6 sm:py-4">
<h2 className="text-base font-semibold text-grayScale-700 sm:text-lg">
Add New Course Add New Course
</h2> </h2>
<button <button
type="button"
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-10 w-10 min-h-[44px] min-w-[44px] place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 sm:h-8 sm:w-8 sm:min-h-0 sm:min-w-0"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-5 px-6 py-6"> <div className="max-h-[min(78vh,840px)] overflow-y-auto px-4 py-5 sm:px-6 sm:py-6">
<div className="space-y-1.5"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-x-6 md:gap-y-4">
<div className="space-y-1.5 md:col-span-2">
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Title Title
</label> </label>
@ -722,9 +769,10 @@ export function SubCoursesPage() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title" placeholder="Enter course title"
className="min-h-[44px]"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 md:col-span-2">
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Description Description
</label> </label>
@ -740,21 +788,77 @@ export function SubCoursesPage() {
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Level Level
</label> </label>
<Input <select
value={level} value={level}
onChange={(e) => setLevel(e.target.value)} onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced" className="min-h-[44px] w-full rounded-lg border border-grayScale-200 bg-white px-3 py-2.5 text-sm transition-colors focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
>
<option value="BEGINNER">BEGINNER</option>
<option value="INTERMEDIATE">INTERMEDIATE</option>
<option value="ADVANCED">ADVANCED</option>
<option value="EXPERT">EXPERT</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-semibold text-grayScale-600">
Sub-level
</label>
<Input
value={subLevel}
onChange={(e) => setSubLevel(e.target.value)}
placeholder='e.g. A1, B2 (CEFR or your scale)'
className="min-h-[44px]"
/> />
</div> </div>
<div className="space-y-1.5 md:col-span-2 md:max-w-xs">
<label className="text-sm font-semibold text-grayScale-600">
Display order
</label>
<Input
type="number"
min={0}
value={displayOrder}
onChange={(e) => setDisplayOrder(e.target.value)}
placeholder="1"
className="min-h-[44px]"
/>
</div>
<div className="space-y-3 md:col-span-2">
<label className="text-sm font-semibold text-grayScale-600">
Thumbnail
</label>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<FileUpload
variant="compact"
accept="image/*"
onFileSelect={setThumbnailFile}
label="Upload thumbnail image"
description="JPEG, PNG, WEBP — or paste a URL"
className="border-grayScale-200 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<div className="space-y-1.5">
<span className="text-xs font-medium text-grayScale-500">
Thumbnail URL (optional)
</span>
<Input
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://..."
className="min-h-[44px]"
/>
</div>
</div>
</div>
{saveError && ( {saveError && (
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600"> <div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 md:col-span-2">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
{saveError} {saveError}
</div> </div>
)} )}
</div> </div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-4 py-4 sm:flex-row sm:justify-end sm:px-6">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowAddModal(false)} onClick={() => setShowAddModal(false)}
@ -912,22 +1016,28 @@ export function SubCoursesPage() {
{/* Edit Sub-course Modal */} {/* Edit Sub-course Modal */}
{showEditModal && subCourseToEdit && ( {showEditModal && subCourseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-black/40 p-3 backdrop-blur-sm sm:p-6">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl"> <div
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> role="dialog"
<h2 className="text-lg font-semibold text-grayScale-700"> aria-modal="true"
className="my-auto w-full max-w-3xl flex-shrink-0 rounded-2xl bg-white shadow-2xl"
>
<div className="flex items-center justify-between border-b border-grayScale-100 px-4 py-3 sm:px-6 sm:py-4">
<h2 className="text-base font-semibold text-grayScale-700 sm:text-lg">
Edit Course Edit Course
</h2> </h2>
<button <button
type="button"
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="grid h-8 w-8 place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600" className="grid h-10 w-10 min-h-[44px] min-w-[44px] place-items-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 sm:h-8 sm:w-8 sm:min-h-0 sm:min-w-0"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="space-y-5 px-6 py-6"> <div className="max-h-[min(78vh,640px)] overflow-y-auto px-4 py-5 sm:px-6 sm:py-6">
<div className="space-y-1.5"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-x-6 md:gap-y-4">
<div className="space-y-1.5 md:col-span-2">
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Title Title
</label> </label>
@ -935,9 +1045,10 @@ export function SubCoursesPage() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title" placeholder="Enter course title"
className="min-h-[44px]"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 md:col-span-2">
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Description Description
</label> </label>
@ -949,25 +1060,27 @@ export function SubCoursesPage() {
rows={3} rows={3}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 md:col-span-2 md:max-w-md">
<label className="text-sm font-semibold text-grayScale-600"> <label className="text-sm font-semibold text-grayScale-600">
Level Level
</label> </label>
<Input <Input
value={level} value={level}
onChange={(e) => setLevel(e.target.value)} onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced" placeholder="e.g., BEGINNER, Intermediate, Advanced"
className="min-h-[44px]"
/> />
</div> </div>
{saveError && ( {saveError && (
<div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600"> <div className="flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 md:col-span-2">
<AlertCircle className="h-4 w-4 shrink-0" /> <AlertCircle className="h-4 w-4 shrink-0" />
{saveError} {saveError}
</div> </div>
)} )}
</div> </div>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-6 py-4 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 border-t border-grayScale-100 px-4 py-4 sm:flex-row sm:justify-end sm:px-6">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}

View File

@ -196,6 +196,7 @@ export interface SubCourse {
level: string level: string
thumbnail: string thumbnail: string
display_order: number display_order: number
sub_level?: string
is_active: boolean is_active: boolean
} }
@ -210,11 +211,15 @@ export interface GetSubCoursesResponse {
metadata: unknown metadata: unknown
} }
/** POST /course-management/sub-courses */
export interface CreateSubCourseRequest { export interface CreateSubCourseRequest {
course_id: number course_id: number
title: string title: string
description: string description: string
thumbnail: string
display_order: number
level: string level: string
sub_level: string
} }
export interface UpdateSubCourseRequest { export interface UpdateSubCourseRequest {