2026-09-15 23:37:48 +07:00
|
|
|
import { useState, useEffect, useRef } from 'react';
|
2026-09-09 22:16:47 +07:00
|
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
|
|
|
import { Card } from '../../components/ui/card';
|
|
|
|
|
import { Button } from '../../components/ui/button';
|
|
|
|
|
import { Input } from '../../components/ui/input';
|
|
|
|
|
import { Label } from '../../components/ui/label';
|
|
|
|
|
import { Textarea } from '../../components/ui/textarea';
|
|
|
|
|
import { Switch } from '../../components/ui/switch';
|
|
|
|
|
import { Separator } from '../../components/ui/separator';
|
2026-09-15 23:37:48 +07:00
|
|
|
import { toast } from 'sonner';
|
2026-09-09 22:16:47 +07:00
|
|
|
import {
|
|
|
|
|
HiOutlineArrowLeft,
|
|
|
|
|
HiOutlineBookOpen,
|
|
|
|
|
HiOutlineChevronRight,
|
2026-09-15 23:37:48 +07:00
|
|
|
HiOutlinePhoto,
|
|
|
|
|
HiOutlineTrash,
|
2026-09-09 22:16:47 +07:00
|
|
|
} from 'react-icons/hi2';
|
|
|
|
|
import courseService from '@/services/courseService';
|
2026-09-15 23:37:48 +07:00
|
|
|
import fileService from '@/services/fileService';
|
|
|
|
|
|
|
|
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
2026-09-09 22:16:47 +07:00
|
|
|
|
|
|
|
|
function CourseFormPage() {
|
|
|
|
|
const { courseId } = useParams();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const isEditing = Boolean(courseId);
|
2026-09-15 23:37:48 +07:00
|
|
|
const fileInputRef = useRef(null);
|
2026-09-09 22:16:47 +07:00
|
|
|
|
|
|
|
|
const [form, setForm] = useState({
|
|
|
|
|
courseCode: '',
|
|
|
|
|
title: '',
|
|
|
|
|
slug: '',
|
|
|
|
|
description: '',
|
|
|
|
|
image: '',
|
|
|
|
|
isPublished: false,
|
|
|
|
|
});
|
2026-09-15 23:37:48 +07:00
|
|
|
const [imageUrl, setImageUrl] = useState('');
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
2026-09-09 22:16:47 +07:00
|
|
|
const [loading, setLoading] = useState(isEditing);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isEditing) return;
|
|
|
|
|
const fetchCourse = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await courseService.getById(courseId);
|
|
|
|
|
setForm({
|
|
|
|
|
courseCode: data.courseCode || '',
|
|
|
|
|
title: data.title || '',
|
|
|
|
|
slug: data.slug || '',
|
|
|
|
|
description: data.description || '',
|
|
|
|
|
image: data.image || '',
|
|
|
|
|
isPublished: data.isPublished || false,
|
|
|
|
|
});
|
2026-09-15 23:37:48 +07:00
|
|
|
setImageUrl(data.imageUrl || data.image || '');
|
2026-09-09 22:16:47 +07:00
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message || 'Failed to load course');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
fetchCourse();
|
|
|
|
|
}, [courseId, isEditing]);
|
|
|
|
|
|
|
|
|
|
const handleChange = (field, value) => {
|
|
|
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-15 23:37:48 +07:00
|
|
|
const handleImagePick = async (event) => {
|
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
event.target.value = '';
|
|
|
|
|
if (!file) return;
|
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
|
|
|
toast.error('Only image files are allowed');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (file.size > MAX_IMAGE_BYTES) {
|
|
|
|
|
toast.error('Image must not exceed 5MB');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setUploading(true);
|
|
|
|
|
try {
|
|
|
|
|
const uploaded = await fileService.upload(file, 'courses');
|
|
|
|
|
handleChange('image', uploaded.objectKey);
|
|
|
|
|
setImageUrl(uploaded.url);
|
|
|
|
|
} catch {
|
|
|
|
|
// global API interceptor already surfaced the error toast
|
|
|
|
|
} finally {
|
|
|
|
|
setUploading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRemoveImage = () => {
|
|
|
|
|
handleChange('image', '');
|
|
|
|
|
setImageUrl('');
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-09 22:16:47 +07:00
|
|
|
const generateSlug = () => {
|
|
|
|
|
const slug = form.title
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.normalize('NFD')
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
|
|
|
.replace(/đ/g, 'd')
|
|
|
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
|
|
|
|
.trim();
|
|
|
|
|
handleChange('slug', slug);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSave = async (publish) => {
|
|
|
|
|
if (!form.courseCode.trim() || !form.title.trim()) {
|
|
|
|
|
setError('Course code and title are required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSaving(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const payload = { ...form, isPublished: publish };
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
await courseService.update(courseId, payload);
|
|
|
|
|
} else {
|
|
|
|
|
const created = await courseService.create(payload);
|
2026-09-22 22:37:36 +07:00
|
|
|
navigate(`/courses/detail/${created.id}`);
|
2026-09-09 22:16:47 +07:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-22 22:37:36 +07:00
|
|
|
navigate(`/courses/detail/${courseId}`);
|
2026-09-09 22:16:47 +07:00
|
|
|
} catch (err) {
|
|
|
|
|
setError(err.message || 'Failed to save course');
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
|
|
|
|
{/* Breadcrumb */}
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-app-text-muted mb-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => navigate('/courses')}
|
|
|
|
|
className="hover:text-app-primary transition-colors flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
<HiOutlineBookOpen className="w-4 h-4" />
|
|
|
|
|
Courses
|
|
|
|
|
</button>
|
|
|
|
|
<HiOutlineChevronRight className="w-3.5 h-3.5" />
|
|
|
|
|
<span className="font-medium text-app-text">
|
|
|
|
|
{isEditing ? 'Edit Course' : 'New Course'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="flex flex-row items-center justify-between mb-6">
|
|
|
|
|
<h2 className="text-xl font-bold text-app-text">
|
|
|
|
|
{isEditing ? 'Edit Course' : 'Create New Course'}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
2026-09-22 22:37:36 +07:00
|
|
|
onClick={() => navigate(isEditing ? `/courses/detail/${courseId}` : '/courses')}
|
2026-09-09 22:16:47 +07:00
|
|
|
disabled={saving}
|
|
|
|
|
>
|
|
|
|
|
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
|
|
|
|
onClick={() => handleSave(false)}
|
2026-09-15 23:37:48 +07:00
|
|
|
disabled={saving || uploading}
|
2026-09-09 22:16:47 +07:00
|
|
|
>
|
|
|
|
|
{saving ? 'Saving...' : 'Save Draft'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
|
|
|
|
onClick={() => handleSave(true)}
|
2026-09-15 23:37:48 +07:00
|
|
|
disabled={saving || uploading}
|
2026-09-09 22:16:47 +07:00
|
|
|
>
|
|
|
|
|
{saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Separator className="mb-6" />
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
className="rounded-xl border border-app-border bg-white overflow-hidden"
|
|
|
|
|
style={{ boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px' }}
|
|
|
|
|
>
|
|
|
|
|
<div className="p-5 border-b border-app-border">
|
|
|
|
|
<h4 className="font-semibold text-app-text">Course Details</h4>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-5 space-y-4">
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
|
|
|
|
Course Code <span className="text-red-500">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="e.g. LDR-101"
|
|
|
|
|
value={form.courseCode}
|
|
|
|
|
onChange={(e) => handleChange('courseCode', e.target.value)}
|
|
|
|
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
|
|
|
|
Title <span className="text-red-500">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Course title"
|
|
|
|
|
value={form.title}
|
|
|
|
|
onChange={(e) => handleChange('title', e.target.value)}
|
|
|
|
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
|
|
|
<Label className="block text-app-text text-xs font-semibold uppercase tracking-wider">
|
|
|
|
|
Slug
|
|
|
|
|
</Label>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={generateSlug}
|
|
|
|
|
className="text-app-primary hover:underline text-xs font-medium"
|
|
|
|
|
>
|
|
|
|
|
Auto-generate from title
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex rounded-xl overflow-hidden bg-app-surface-muted">
|
|
|
|
|
<span className="px-4 py-2 text-app-text-muted font-mono text-sm bg-app-border/30 flex items-center select-none">
|
|
|
|
|
/courses/
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.slug}
|
|
|
|
|
onChange={(e) => handleChange('slug', e.target.value)}
|
|
|
|
|
className="border-0 bg-transparent font-mono focus:ring-0 focus:bg-transparent"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
|
|
|
|
Description
|
|
|
|
|
</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
placeholder="Course description..."
|
|
|
|
|
rows={4}
|
|
|
|
|
value={form.description}
|
|
|
|
|
onChange={(e) => handleChange('description', e.target.value)}
|
|
|
|
|
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary resize-none"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
2026-09-15 23:37:48 +07:00
|
|
|
Image
|
2026-09-09 22:16:47 +07:00
|
|
|
</Label>
|
2026-09-15 23:37:48 +07:00
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-label="Upload course image"
|
|
|
|
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if ((e.key === 'Enter' || e.key === ' ') && !uploading) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="group relative h-48 w-full cursor-pointer overflow-hidden rounded-xl border border-dashed border-app-border bg-app-surface-muted"
|
|
|
|
|
>
|
|
|
|
|
{imageUrl ? (
|
|
|
|
|
<>
|
|
|
|
|
<img src={imageUrl} alt="Course" className="h-full w-full object-cover" />
|
|
|
|
|
{!uploading && (
|
|
|
|
|
<div className="absolute inset-0 hidden items-center justify-center bg-black/40 text-sm font-medium text-white group-hover:flex">
|
|
|
|
|
Replace
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Remove image"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleRemoveImage();
|
|
|
|
|
}}
|
|
|
|
|
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-lg bg-white/90 text-red-500 shadow-sm hover:bg-white"
|
|
|
|
|
>
|
|
|
|
|
<HiOutlineTrash className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full flex-col items-center justify-center gap-1 text-app-text-muted">
|
|
|
|
|
<HiOutlinePhoto className="h-8 w-8" />
|
|
|
|
|
<span className="text-sm font-medium text-app-text">Click to upload image</span>
|
|
|
|
|
<span className="text-xs">PNG, JPG, WEBP — max 5MB</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{uploading && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 text-sm font-medium text-white">
|
|
|
|
|
Uploading...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/*"
|
|
|
|
|
className="hidden"
|
|
|
|
|
onChange={handleImagePick}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-09-09 22:16:47 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium text-sm text-app-text">Published</span>
|
|
|
|
|
<span className="text-xs text-app-text-muted">Make this course visible to students</span>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
checked={form.isPublished}
|
|
|
|
|
onCheckedChange={(checked) => handleChange('isPublished', checked)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default CourseFormPage;
|