fix: top menu
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
@@ -8,17 +7,24 @@ import { Label } from '../../components/ui/label';
|
||||
import { Textarea } from '../../components/ui/textarea';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { Separator } from '../../components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineBookOpen,
|
||||
HiOutlineChevronRight,
|
||||
HiOutlinePhoto,
|
||||
HiOutlineTrash,
|
||||
} from 'react-icons/hi2';
|
||||
import courseService from '@/services/courseService';
|
||||
import fileService from '@/services/fileService';
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function CourseFormPage() {
|
||||
const { courseId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEditing = Boolean(courseId);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
courseCode: '',
|
||||
@@ -28,6 +34,8 @@ function CourseFormPage() {
|
||||
image: '',
|
||||
isPublished: false,
|
||||
});
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(isEditing);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -45,6 +53,7 @@ function CourseFormPage() {
|
||||
image: data.image || '',
|
||||
isPublished: data.isPublished || false,
|
||||
});
|
||||
setImageUrl(data.imageUrl || data.image || '');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load course');
|
||||
} finally {
|
||||
@@ -58,6 +67,35 @@ function CourseFormPage() {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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('');
|
||||
};
|
||||
|
||||
const generateSlug = () => {
|
||||
const slug = form.title
|
||||
.toLowerCase()
|
||||
@@ -84,10 +122,10 @@ function CourseFormPage() {
|
||||
await courseService.update(courseId, payload);
|
||||
} else {
|
||||
const created = await courseService.create(payload);
|
||||
navigate(`/courses/${created.id}`);
|
||||
navigate(`/courses/${created.id}/detail`);
|
||||
return;
|
||||
}
|
||||
navigate(`/courses/${courseId}`);
|
||||
navigate(`/courses/${courseId}/detail`);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save course');
|
||||
} finally {
|
||||
@@ -98,7 +136,6 @@ function CourseFormPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<div className="flex items-center justify-center py-20 text-app-text-muted">Loading course...</div>
|
||||
</>
|
||||
);
|
||||
@@ -106,7 +143,6 @@ function CourseFormPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader />
|
||||
<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">
|
||||
@@ -132,7 +168,7 @@ function CourseFormPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={() => navigate(isEditing ? `/courses/${courseId}` : '/courses')}
|
||||
onClick={() => navigate(isEditing ? `/courses/${courseId}/detail` : '/courses')}
|
||||
disabled={saving}
|
||||
>
|
||||
<HiOutlineArrowLeft className="w-4 h-4 mr-2" />
|
||||
@@ -142,14 +178,14 @@ function CourseFormPage() {
|
||||
variant="outline"
|
||||
className="rounded-xl bg-app-surface-muted text-app-text hover:bg-app-interactive-hover"
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving}
|
||||
disabled={saving || uploading}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-xl bg-app-primary text-white hover:bg-app-primary-dark"
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={saving}
|
||||
disabled={saving || uploading}
|
||||
>
|
||||
{saving ? 'Saving...' : isEditing ? 'Update & Publish' : 'Save & Publish'}
|
||||
</Button>
|
||||
@@ -237,14 +273,61 @@ function CourseFormPage() {
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-app-text text-xs font-semibold uppercase tracking-wider">
|
||||
Image URL
|
||||
Image
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="https://..."
|
||||
value={form.image}
|
||||
onChange={(e) => handleChange('image', e.target.value)}
|
||||
className="rounded-xl bg-app-surface-muted border-0 focus:bg-white focus:ring-2 focus:ring-app-primary"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-app-surface-muted">
|
||||
|
||||
Reference in New Issue
Block a user