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
label?: string
description?: string
/** Shorter, row-oriented layout for wide forms / modals */
variant?: "default" | "compact"
}
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 [dragActive, setDragActive] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
@ -48,10 +50,15 @@ export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
}
}
const isCompact = variant === "compact"
return (
<div
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",
className,
)}
@ -68,27 +75,44 @@ export const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
onChange={handleChange}
{...props}
/>
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="mb-4 grid h-16 w-16 place-items-center rounded-full bg-brand-100 text-brand-600">
<Upload className="h-8 w-8" />
<div
className={cn(
"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>
{file ? (
<>
<p className="mb-1 text-sm font-medium text-grayScale-900">{file.name}</p>
<div className={cn("min-w-0 flex-1", !isCompact && "text-center")}>
<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>
</>
</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"}
</p>
<p className="mb-4 text-xs text-grayScale-500">
<p className="text-xs text-grayScale-500">
{description || "or click to browse files"}
</p>
</div>
<button
type="button"
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
</button>

View File

@ -34,7 +34,7 @@ export function AppLayout() {
}`}
>
<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 />
</main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">

View File

@ -35,7 +35,9 @@ import {
addSubCoursePrerequisite,
removeSubCoursePrerequisite,
} from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api";
import { Input } from "../../components/ui/input";
import { FileUpload } from "../../components/ui/file-upload";
import type {
SubCourse,
Course,
@ -43,6 +45,7 @@ import type {
SubCoursePrerequisite,
} from "../../types/course.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { toast } from "sonner";
export function SubCoursesPage() {
const { categoryId, courseId } = useParams<{
@ -72,7 +75,11 @@ export function SubCoursesPage() {
);
const [title, setTitle] = 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 [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 = () => {
setTitle("");
setDescription("");
setLevel("");
setLevel("BEGINNER");
setSubLevel("");
setThumbnailUrl("");
setThumbnailFile(null);
setDisplayOrder(String(nextSubCourseDisplayOrder()));
setSaveError(null);
setShowAddModal(true);
};
@ -235,20 +251,45 @@ export function SubCoursesPage() {
setSaving(true);
setSaveError(null);
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({
course_id: Number(courseId),
title,
description,
level,
title: title.trim(),
description: description.trim(),
thumbnail,
display_order,
level: level.trim() || "BEGINNER",
sub_level: subLevel.trim(),
});
setShowAddModal(false);
setTitle("");
setDescription("");
setLevel("");
setLevel("BEGINNER");
setSubLevel("");
setThumbnailUrl("");
setThumbnailFile(null);
setDisplayOrder("1");
await fetchSubCourses();
} catch (err) {
toast.success("Course created successfully");
} catch (err: unknown) {
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 {
setSaving(false);
}
@ -649,8 +690,8 @@ export function SubCoursesPage() {
{/* Delete Modal */}
{showDeleteModal && subCourseToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white shadow-2xl">
<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="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">
<h2 className="text-lg font-semibold text-grayScale-700">
Delete Course
@ -697,24 +738,30 @@ export function SubCoursesPage() {
</div>
)}
{/* Add Sub-course Modal */}
{/* Add Sub-course Modal — POST /course-management/sub-courses */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<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">
<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
role="dialog"
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
</h2>
<button
type="button"
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" />
</button>
</div>
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<div className="max-h-[min(78vh,840px)] overflow-y-auto px-4 py-5 sm:px-6 sm:py-6">
<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">
Title
</label>
@ -722,9 +769,10 @@ export function SubCoursesPage() {
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title"
className="min-h-[44px]"
/>
</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">
Description
</label>
@ -740,21 +788,77 @@ export function SubCoursesPage() {
<label className="text-sm font-semibold text-grayScale-600">
Level
</label>
<Input
<select
value={level}
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 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 && (
<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" />
{saveError}
</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
variant="outline"
onClick={() => setShowAddModal(false)}
@ -912,22 +1016,28 @@ export function SubCoursesPage() {
{/* Edit Sub-course Modal */}
{showEditModal && subCourseToEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl bg-white shadow-2xl">
<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">
<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
role="dialog"
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
</h2>
<button
type="button"
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" />
</button>
</div>
<div className="space-y-5 px-6 py-6">
<div className="space-y-1.5">
<div className="max-h-[min(78vh,640px)] overflow-y-auto px-4 py-5 sm:px-6 sm:py-6">
<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">
Title
</label>
@ -935,9 +1045,10 @@ export function SubCoursesPage() {
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter course title"
className="min-h-[44px]"
/>
</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">
Description
</label>
@ -949,25 +1060,27 @@ export function SubCoursesPage() {
rows={3}
/>
</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">
Level
</label>
<Input
value={level}
onChange={(e) => setLevel(e.target.value)}
placeholder="e.g., Beginner, Intermediate, Advanced"
placeholder="e.g., BEGINNER, Intermediate, Advanced"
className="min-h-[44px]"
/>
</div>
{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" />
{saveError}
</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
variant="outline"
onClick={() => setShowEditModal(false)}

View File

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