feat(admin): CourseForm with upload, duration spinner, color picker; refactor create/edit pages

feat(backend): add courses CRUD with validation; add GET /api/instructors

feat(api): add /api/uploads route

chore(proxy): BASE fallback -> http://localhost:8001
This commit is contained in:
unknown 2025-11-14 12:39:27 +07:00
parent bd6e2ebb07
commit 4f06277903
8 changed files with 1122 additions and 0 deletions

577
backend/src/server.js Normal file
View File

@ -0,0 +1,577 @@
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 8000;
app.use(cors());
app.use(express.json());
// In-memory stores
const idempotencyStore = new Map();
const sessions = new Map();
// Instructors list (simple directory)
const instructors = [
{ id: 'i1', name: 'Chef A', email: 'chef.a@example.com' },
{ id: 'i2', name: 'Chef B', email: 'chef.b@example.com' },
{ id: 'i3', name: 'Chef C', email: 'chef.c@example.com' }
];
// Sample data aligned with OpenAPI examples
const courses = [
{
id: 'c1',
title: 'Basic Knife Skills',
instructor: 'Chef A',
progress: 30,
status: 'in_progress',
color: '#FF9800',
thumbnail: '/images/knife-skills.jpg',
duration: '6h',
modules: 12,
enrolled: '1.2k'
},
{
id: 'c2',
title: 'Bread Baking 101',
instructor: 'Chef B',
progress: 0,
status: 'not_started',
color: '#3F51B5',
thumbnail: '/images/bread.jpg',
duration: '4h',
modules: 8,
enrolled: '860'
}
];
const courseDetails = {
c1: {
id: 'c1',
title: 'Basic Knife Skills',
description: 'Learn core knife handling techniques.',
instructor: 'Chef A',
duration: '6h',
totalModules: 12,
completedModules: 3,
progress: 25,
thumbnail: '/images/knife-skills.jpg',
color: '#FF9800',
modules: [
{
id: 'm1',
title: 'Intro',
description: 'Overview',
duration: '15m',
isUnlocked: true,
isCompleted: false,
progress: 0,
type: 'reading',
hasQuiz: false,
hasAssignment: false,
}
],
},
c2: {
id: 'c2',
title: 'Bread Baking 101',
description: 'Learn how to bake bread.',
instructor: 'Chef B',
duration: '4h',
totalModules: 8,
completedModules: 0,
progress: 0,
thumbnail: '/images/bread.jpg',
color: '#3F51B5',
modules: [
{
id: 'm21',
title: 'Starter',
description: 'Basics',
duration: '10m',
isUnlocked: true,
isCompleted: false,
progress: 0,
type: 'video',
hasQuiz: false,
hasAssignment: false,
}
],
}
};
const exams = [
{
id: 1,
title: 'Kuis Interaktif - Keamanan Dapur BGN',
course: 'Keamanan Pangan dan Gizi - Badan Gizi Nasional',
type: 'Interactive Quiz',
date: '2024-01-26',
startTime: '09:00',
endTime: '10:00',
duration: 60,
questions: 10,
status: 'available',
attempts: 0,
maxAttempts: 3,
passingScore: 70,
description: 'Kuis interaktif tentang keamanan pangan dan praktik dapur yang baik di lingkungan BGN',
instructions: [
'Pastikan koneksi internet stabil',
'Siapkan alat tulis',
'Baca soal dengan teliti'
],
isInteractive: true,
score: null,
grade: null,
},
{
id: 2,
title: 'Ujian Tengah Semester - HACCP',
course: 'HACCP - Hazard Analysis Critical Control Point',
type: 'Midterm Exam',
date: '2024-01-28',
startTime: '10:00',
endTime: '12:00',
duration: 120,
questions: 50,
status: 'upcoming',
attempts: 0,
maxAttempts: 1,
passingScore: 75,
description: 'Ujian tengah semester mencakup materi HACCP dari modul 1-6',
instructions: [
'Pastikan koneksi internet stabil',
'Siapkan alat tulis',
'Baca soal dengan teliti'
],
isInteractive: false,
score: null,
grade: null,
},
];
// Answer key for scoring (simple)
const examAnswerKey = {
'exam-001': {
q1: 0,
q2: 2,
q3: 1,
q4: 3,
q5: 0,
q6: 1,
q7: 2,
q8: 1,
q9: 0,
q10: 3,
}
};
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'lms-bgn-backend', time: new Date().toISOString() });
});
// =====================
// Epik 1 — Courses APIs
// =====================
app.get('/api/courses', (req, res) => {
const page = parseInt(req.query.page || '1', 10);
const pageSize = parseInt(req.query.pageSize || '20', 10);
if (isNaN(page) || isNaN(pageSize) || page < 1 || pageSize < 1 || pageSize > 100) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
const q = (req.query.q || '').toString().toLowerCase();
const filtered = courses.filter(c => !q || c.title.toLowerCase().includes(q));
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
res.json({ page, pageSize, total: filtered.length, items });
});
app.get('/api/courses/:courseId', (req, res) => {
const { courseId } = req.params;
const detail = courseDetails[courseId];
if (!detail) return res.status(404).json({ error: 'Not Found' });
res.json(detail);
});
// Instructors directory
app.get('/api/instructors', (req, res) => {
const q = (req.query.q || '').toString().toLowerCase();
const items = instructors.filter(i => !q || i.name.toLowerCase().includes(q) || i.email.toLowerCase().includes(q));
res.json({ items, total: items.length });
});
// Create Course
app.post('/api/courses', (req, res) => {
const { title, description, instructor, duration, thumbnail, color, status } = req.body || {};
const errors = {};
if (!title || String(title).trim().length < 3) {
errors.title = 'Judul minimal 3 karakter';
}
if (!instructor || String(instructor).trim().length < 3) {
errors.instructor = 'Instruktur minimal 3 karakter';
}
if (!duration || typeof duration !== 'string') {
errors.duration = 'Durasi wajib';
} else {
const okDur = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(duration.trim());
if (!okDur) errors.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
}
if (!thumbnail || typeof thumbnail !== 'string') {
errors.thumbnail = 'Thumbnail wajib';
} else {
const t = thumbnail.trim();
const okT = t.startsWith('/') || /^https?:\/\//.test(t);
if (!okT) errors.thumbnail = 'Thumbnail harus URL http(s) atau path dimulai /';
}
if (color) {
const okColor = /^#([A-Fa-f0-9]{6})$/.test(String(color).trim());
if (!okColor) errors.color = 'Warna harus hex contoh #FF9800';
}
if (status && !['not_started', 'in_progress', 'completed'].includes(status)) {
errors.status = 'Status tidak valid';
}
if (Object.keys(errors).length > 0) {
return res.status(400).json({ error: 'Invalid request', details: errors });
}
const newId = `c${Date.now()}`;
const summary = {
id: newId,
title: String(title),
instructor: String(instructor),
progress: 0,
status: status || 'not_started',
color: color || '#3F51B5',
thumbnail: String(thumbnail),
duration: String(duration),
modules: 0,
enrolled: '0'
};
courses.unshift(summary);
courseDetails[newId] = {
id: newId,
title: String(title),
description: description || '',
instructor: String(instructor),
duration: String(duration),
totalModules: 0,
completedModules: 0,
progress: 0,
thumbnail: String(thumbnail),
color: color || '#3F51B5',
modules: [],
};
res.status(201).json({ id: newId, status: 'created' });
});
// Update Course
app.put('/api/courses/:courseId', (req, res) => {
const { courseId } = req.params;
const existingDetail = courseDetails[courseId];
if (!existingDetail) return res.status(404).json({ error: 'Not Found' });
const { title, description, instructor, duration, thumbnail, color, status } = req.body || {};
const errors = {};
if (title !== undefined && String(title).trim().length < 3) {
errors.title = 'Judul minimal 3 karakter';
}
if (instructor !== undefined && String(instructor).trim().length < 3) {
errors.instructor = 'Instruktur minimal 3 karakter';
}
if (duration !== undefined) {
const okDur = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(String(duration).trim());
if (!okDur) errors.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
}
if (thumbnail !== undefined) {
const t = String(thumbnail).trim();
const okT = t.startsWith('/') || /^https?:\/\//.test(t);
if (!okT) errors.thumbnail = 'Thumbnail harus URL http(s) atau path dimulai /';
}
if (color !== undefined) {
const okColor = /^#([A-Fa-f0-9]{6})$/.test(String(color).trim());
if (!okColor) errors.color = 'Warna harus hex contoh #FF9800';
}
if (status !== undefined && !['not_started', 'in_progress', 'completed'].includes(status)) {
errors.status = 'Status tidak valid';
}
if (Object.keys(errors).length > 0) {
return res.status(400).json({ error: 'Invalid request', details: errors });
}
// Update detail
const updatedDetail = { ...existingDetail };
if (title !== undefined) updatedDetail.title = String(title);
if (description !== undefined) updatedDetail.description = String(description);
if (instructor !== undefined) updatedDetail.instructor = String(instructor);
if (duration !== undefined) updatedDetail.duration = String(duration);
if (thumbnail !== undefined) updatedDetail.thumbnail = String(thumbnail);
if (color !== undefined) updatedDetail.color = String(color);
if (status !== undefined) updatedDetail.status = status;
courseDetails[courseId] = updatedDetail;
// Update summary
const idx = courses.findIndex(c => c.id === courseId);
if (idx >= 0) {
const updatedSummary = { ...courses[idx] };
if (title !== undefined) updatedSummary.title = String(title);
if (instructor !== undefined) updatedSummary.instructor = String(instructor);
if (duration !== undefined) updatedSummary.duration = String(duration);
if (thumbnail !== undefined) updatedSummary.thumbnail = String(thumbnail);
if (color !== undefined) updatedSummary.color = String(color);
if (status !== undefined) updatedSummary.status = status;
courses[idx] = updatedSummary;
}
res.json({ id: courseId, status: 'updated' });
});
// Delete Course
app.delete('/api/courses/:courseId', (req, res) => {
const { courseId } = req.params;
const idx = courses.findIndex(c => c.id === courseId);
if (idx < 0) return res.status(404).json({ error: 'Not Found' });
courses.splice(idx, 1);
delete courseDetails[courseId];
res.status(204).send();
});
app.post('/api/modules/:moduleId/progress', (req, res) => {
const { moduleId } = req.params;
const { progressPercent, updatedAt } = req.body || {};
if (typeof progressPercent !== 'number' || progressPercent < 0 || progressPercent > 100) {
return res.status(400).json({ error: 'Invalid progressPercent' });
}
// Accept and simulate async processing
res.status(202).json({ status: 'accepted', moduleId, updatedAt: updatedAt || new Date().toISOString() });
});
app.post('/api/assignments/:assignmentId/submission', (req, res) => {
const { assignmentId } = req.params;
const { content, attachments, idempotencyKey } = req.body || {};
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
const key = `assignment-${idempotencyKey}`;
if (idempotencyStore.has(key)) {
return res.status(409).json({ error: 'Conflict (duplicate idempotency key)' });
}
idempotencyStore.set(key, { assignmentId, at: Date.now() });
const submissionId = `sub-${Date.now()}`;
res.status(201).json({ submissionId, status: 'created' });
});
// =====================
// Epik 2 — Exams APIs
// =====================
const AUTH_TOKEN = process.env.TEST_BEARER_TOKEN || 'test-token';
const requireAuth = (req, res, next) => {
const auth = req.headers.authorization || '';
if (!auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
const token = auth.slice('Bearer '.length);
if (token !== AUTH_TOKEN) return res.status(401).json({ error: 'Unauthorized' });
next();
};
app.post('/api/exam-session', requireAuth, (req, res) => {
const { examId, userId } = req.body || {};
if (!examId || !userId) return res.status(400).json({ error: 'Missing examId or userId' });
const sessionId = `sess-${Date.now()}`;
const startTime = new Date().toISOString();
sessions.set(sessionId, { examId, userId, startTime });
res.status(201).json({ sessionId, status: 'created', startTime });
});
app.post('/api/exam-session/:sessionId/score', requireAuth, (req, res) => {
const { sessionId } = req.params;
const session = sessions.get(sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
const { idempotencyKey, answers, mode } = req.body || {};
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
const key = `score-${idempotencyKey}`;
if (idempotencyStore.has(key)) {
return res.status(409).json({ error: 'Conflict (duplicate idempotency key)' });
}
idempotencyStore.set(key, { sessionId, at: Date.now() });
const perQuestion = (answers || []).map(a => scoreAnswer(session.examId, a));
const totalScore = perQuestion.reduce((sum, p) => sum + p.earnedPoints, 0);
const totalQuestions = perQuestion.length;
const correctAnswers = perQuestion.filter(p => p.isCorrect).length;
const result = { totalScore, totalQuestions, correctAnswers, perQuestion };
sessions.set(sessionId, { ...session, lastScore: result, lastAnswers: perQuestion, completedAt: new Date().toISOString() });
res.json(result);
});
app.get('/api/exam-session/:sessionId/summary', requireAuth, (req, res) => {
const { sessionId } = req.params;
const session = sessions.get(sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
const { lastScore, lastAnswers } = session;
const summary = {
examId: session.examId || 'exam-001',
examTitle: 'Kuis Interaktif - Keamanan Dapur BGN',
totalQuestions: lastScore ? lastScore.totalQuestions : 0,
correctAnswers: lastScore ? lastScore.correctAnswers : 0,
score: lastScore ? lastScore.totalScore : 0,
timeSpent: '25 menit 30 detik',
completedAt: session.completedAt || new Date().toISOString(),
answers: (lastAnswers || []).map(a => ({
questionId: a.questionId,
isCorrect: a.isCorrect,
type: a.type,
earnedPoints: a.earnedPoints,
maxPoints: a.maxPoints,
details: a.details,
})),
};
res.json(summary);
});
app.get('/api/exams', (req, res) => {
const page = parseInt(req.query.page || '1', 10);
const pageSize = parseInt(req.query.pageSize || '20', 10);
if (isNaN(page) || isNaN(pageSize) || page < 1 || pageSize < 1 || pageSize > 100) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
const q = (req.query.q || '').toString().toLowerCase();
const status = (req.query.status || '').toString();
const filtered = exams.filter(e => {
const okQ = !q || e.title.toLowerCase().includes(q) || e.course.toLowerCase().includes(q);
const okStatus = !status || e.status === status;
return okQ && okStatus;
});
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
res.json({ page, pageSize, total: filtered.length, items });
});
app.post('/api/certificates/:certificateId/resend', (req, res) => {
const { certificateId } = req.params;
const { recipientEmail, idempotencyKey } = req.body || {};
if (!recipientEmail) return res.status(400).json({ error: 'Missing recipientEmail' });
if (!idempotencyKey) return res.status(400).json({ error: 'Missing idempotencyKey' });
const key = `cert-${idempotencyKey}`;
const prev = idempotencyStore.get(key);
if (prev && Date.now() - prev.at < 30000) {
return res.status(429).json({ error: 'Too Many Requests' });
}
idempotencyStore.set(key, { certificateId, at: Date.now() });
const jobId = `job-${Date.now()}`;
res.status(202).json({ jobId, status: 'accepted' });
});
// Global error handler (fallback)
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`LMS-BGN backend running on http://localhost:${PORT}`);
});
}
module.exports = app;
const interactiveAnswerKey = {
'exam-001': {
'mcq-1': { type: 'enhanced_mcq', correctChoiceIndex: 2, maxPoints: 10 },
'video-1': { type: 'video_scenario', steps: [ { stepId: 's1', correctOptionId: 'optA' }, { stepId: 's2', correctOptionId: 'optB' } ], maxPoints: 10 },
'hotspot-1': { type: 'image_hotspot', correctHotspots: ['spot1','spot3'], maxPoints: 10 },
'gallery-1': { type: 'media_gallery', minViewed: 3, maxPoints: 10 },
'puzzle-1': { type: 'puzzle', matches: [ { pieceId: 'p1', targetId: 't1' }, { pieceId: 'p2', targetId: 't2' } ], maxPoints: 10 },
'scenario-1': { type: 'scenario', correctOptionId: 'optX', maxPoints: 10 },
}
};
function scoreAnswer(sessionExamId, a) {
const maxPointsDefault = 10;
const type = a.type || 'multiple-choice';
const ik = interactiveAnswerKey[sessionExamId] || {};
const key = ik[a.questionId];
let earnedPoints = 0;
let maxPoints = (key && key.maxPoints) || maxPointsDefault;
let isCorrect = false;
let details = {};
switch (type) {
case 'enhanced_mcq':
case 'multiple-choice': {
const ak = examAnswerKey[sessionExamId] || {};
const correctIndex = key && key.correctChoiceIndex !== undefined ? key.correctChoiceIndex : ak[a.questionId];
isCorrect = typeof correctIndex === 'number' && a.choiceIndex === correctIndex;
earnedPoints = isCorrect ? maxPoints : 0;
details = { choiceIndex: a.choiceIndex, correctIndex };
break;
}
case 'video_scenario': {
const expected = (key && key.steps) || [];
const steps = Array.isArray(a.scenarioAnswers) ? a.scenarioAnswers : [];
let correctCount = 0;
const stepDetails = steps.map(s => {
const exp = expected.find(e => e.stepId === s.stepId);
const ok = !!exp && s.selectedOptionId === exp.correctOptionId;
if (ok) correctCount++;
return { stepId: s.stepId, selectedOptionId: s.selectedOptionId, correctOptionId: exp ? exp.correctOptionId : null, isCorrect: ok };
});
const total = expected.length || steps.length || 1;
earnedPoints = Math.round((correctCount / total) * maxPoints);
isCorrect = correctCount === total && total > 0;
details = { steps: stepDetails, correctCount, totalSteps: total };
break;
}
case 'image_hotspot': {
const selected = Array.isArray(a.selectedHotspots) ? a.selectedHotspots : [];
const expected = (key && key.correctHotspots) || [];
const setExp = new Set(expected);
let matchCount = 0;
selected.forEach(h => { if (setExp.has(h)) matchCount++; });
const totalRequired = expected.length || 1;
earnedPoints = Math.round((matchCount / totalRequired) * maxPoints);
isCorrect = matchCount === totalRequired && totalRequired > 0;
details = { selectedHotspots: selected, correctHotspots: expected, matchCount, totalRequired };
break;
}
case 'media_gallery': {
const viewed = Array.isArray(a.viewedItems) ? a.viewedItems : [];
const minViewed = (key && key.minViewed) || 3;
const ratio = Math.min(viewed.length, minViewed) / minViewed;
earnedPoints = Math.round(ratio * maxPoints);
isCorrect = viewed.length >= minViewed;
details = { viewedCount: viewed.length, minViewed };
break;
}
case 'puzzle': {
const matches = Array.isArray(a.matches || a.solvedSteps) ? (a.matches || a.solvedSteps) : [];
const expected = (key && key.matches) || [];
let correctCount = 0;
const matchDetails = matches.map(m => {
const ok = expected.some(e => e.pieceId === m.pieceId && e.targetId === m.targetId);
if (ok) correctCount++;
return { pieceId: m.pieceId, targetId: m.targetId, isCorrect: ok };
});
const totalPairs = expected.length || matches.length || 1;
earnedPoints = Math.round((correctCount / totalPairs) * maxPoints);
isCorrect = correctCount === totalPairs && totalPairs > 0;
details = { solvedSteps: matchDetails, correctCount, totalPairs };
break;
}
case 'scenario': {
const correctOptionId = key && key.correctOptionId;
isCorrect = !!correctOptionId && a.selectedOptionId === correctOptionId;
earnedPoints = isCorrect ? maxPoints : 0;
details = { selectedOptionId: a.selectedOptionId, correctOptionId };
break;
}
default: {
const ak = examAnswerKey[sessionExamId] || {};
const correctIndex = ak[a.questionId];
isCorrect = typeof correctIndex === 'number' && a.choiceIndex === correctIndex;
earnedPoints = isCorrect ? maxPoints : 0;
details = { choiceIndex: a.choiceIndex, correctIndex };
}
}
return { questionId: a.questionId, type, isCorrect, earnedPoints, maxPoints, details };
}

View File

@ -0,0 +1,57 @@
"use client";
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import CourseForm, { CourseFormValues } from '@/components/admin/CourseForm';
import { showToast } from '@/lib/toast';
export default function AdminCourseEditPage() {
const params = useParams() as { id: string };
const courseId = params?.id;
const router = useRouter();
const [initial, setInitial] = useState<CourseFormValues | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const ctrl = new AbortController();
(async () => {
setLoading(true);
try {
const res = await fetch(`/api/courses/${encodeURIComponent(courseId)}`, { signal: ctrl.signal });
if (!res.ok) throw new Error(`Gagal memuat (${res.status})`);
const json = await res.json();
setInitial({
title: String(json?.title ?? ''),
description: json?.description ?? '',
instructor: json?.instructor ?? '',
duration: json?.duration ?? '',
thumbnail: json?.thumbnail ?? '',
color: json?.color ?? '#3F51B5',
status: json?.status ?? 'not_started',
});
} catch (e: any) {
showToast(e?.message || 'Gagal memuat data kursus', 'error');
} finally {
setLoading(false);
}
})();
return () => ctrl.abort();
}, [courseId]);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Edit Kursus</h1>
<p className="text-gray-600">ID: {courseId}</p>
</div>
<Link href={`/admin/courses/${courseId}`} className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Kembali</Link>
</div>
{loading && <div className="p-4 bg-white border rounded-lg">Memuat data kursus...</div>}
{!loading && initial && (
<CourseForm mode="edit" courseId={courseId} initial={initial} onDone={() => setTimeout(() => router.push(`/admin/courses/${courseId}`), 600)} />
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
"use client";
import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import CourseForm from '@/components/admin/CourseForm';
export default function AdminCourseCreatePage() {
const router = useRouter();
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Buat Kursus Baru</h1>
<p className="text-gray-600">Isi detail kursus kemudian simpan</p>
</div>
<Link href="/admin/courses" className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Kembali</Link>
</div>
<CourseForm mode="create" onDone={() => setTimeout(() => router.push('/admin/courses'), 600)} />
</div>
);
}

View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
export async function GET(_req: NextRequest, { params }: { params: { courseId: string } }) {
const { courseId } = params;
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
try {
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error', status: res.status }, { status: res.status });
}
const json = await res.json();
return NextResponse.json(json);
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}
export async function PUT(req: NextRequest, { params }: { params: { courseId: string } }) {
const { courseId } = params;
try {
const payload = await req.json();
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
const res = await fetch(upstreamUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
const text = await res.text();
let json: any = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
}
return NextResponse.json(json, { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}
export async function DELETE(_req: NextRequest, { params }: { params: { courseId: string } }) {
const { courseId } = params;
const upstreamUrl = `${BASE}/api/courses/${encodeURIComponent(courseId)}`;
try {
const res = await fetch(upstreamUrl, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
const text = await res.text();
let json: any = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
}
return NextResponse.json(json, { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const page = url.searchParams.get('page') || '1';
const limit = url.searchParams.get('limit') || '20';
const q = url.searchParams.get('q') || '';
const upstreamUrl = `${BASE}/api/courses?page=${encodeURIComponent(page)}&pageSize=${encodeURIComponent(limit)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
try {
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error', status: res.status }, { status: res.status });
}
const json = await res.json();
return NextResponse.json({
data: json.items || [],
total: json.total ?? 0,
page: json.page ?? Number(page),
limit: json.pageSize ?? Number(limit),
q,
});
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
try {
const payload = await req.json();
const upstreamUrl = `${BASE}/api/courses`;
const res = await fetch(upstreamUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
const text = await res.text();
let json: any = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) {
return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
}
return NextResponse.json(json, { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}

View File

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const BASE = process.env.BACKEND_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8001';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const q = url.searchParams.get('q') || '';
const upstreamUrl = `${BASE}/api/instructors${q ? `?q=${encodeURIComponent(q)}` : ''}`;
try {
const res = await fetch(upstreamUrl, { headers: { Accept: 'application/json' } });
const text = await res.text();
let json: any = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) return NextResponse.json({ error: 'Upstream error', status: res.status, data: json }, { status: res.status });
return NextResponse.json(json);
} catch (err: any) {
return NextResponse.json({ error: 'Failed to fetch upstream', message: err?.message }, { status: 502 });
}
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export const runtime = 'nodejs';
export async function POST(req: NextRequest) {
try {
const form = await req.formData();
const file = form.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'File tidak ditemukan' }, { status: 400 });
}
const type = file.type || '';
if (!type.startsWith('image/')) {
return NextResponse.json({ error: 'Hanya gambar yang diperbolehkan' }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length > 5 * 1024 * 1024) {
return NextResponse.json({ error: 'Ukuran gambar maksimal 5MB' }, { status: 400 });
}
const uploadsDir = path.join(process.cwd(), 'public', 'uploads');
await fs.promises.mkdir(uploadsDir, { recursive: true });
const ext = path.extname(file.name) || (type.includes('png') ? '.png' : type.includes('jpeg') ? '.jpg' : '');
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
const filePath = path.join(uploadsDir, filename);
await fs.promises.writeFile(filePath, buffer);
const publicPath = `/uploads/${filename}`;
return NextResponse.json({ path: publicPath });
} catch (err: any) {
return NextResponse.json({ error: 'Upload gagal', message: err?.message }, { status: 500 });
}
}

View File

@ -0,0 +1,296 @@
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import { showToast } from '@/lib/toast';
type Status = 'not_started' | 'in_progress' | 'completed';
export type CourseFormValues = {
title: string;
description?: string;
instructor?: string; // name string per backend schema
duration?: string; // e.g., "6h", "30m", "1h 30m"
thumbnail?: string; // public path / URL
color?: string; // hex
status?: Status;
};
type Props = {
mode: 'create' | 'edit';
courseId?: string;
initial?: CourseFormValues;
onDone?: (result: any) => void;
};
type Instructor = { id: string; name: string; email?: string };
export default function CourseForm({ mode, courseId, initial, onDone }: Props) {
const [title, setTitle] = useState(initial?.title || '');
const [description, setDescription] = useState(initial?.description || '');
const [instructor, setInstructor] = useState(initial?.instructor || '');
const [hours, setHours] = useState(() => {
if (!initial?.duration) return 0;
const hMatch = /(?<h>\d+)h/.exec(initial.duration || '');
return hMatch ? Number(hMatch.groups?.h) : 0;
});
const [minutes, setMinutes] = useState(() => {
if (!initial?.duration) return 0;
const mMatch = /(?<m>\d+)m/.exec(initial.duration || '');
return mMatch ? Number(mMatch.groups?.m) : 0;
});
const [color, setColor] = useState(initial?.color || '#3F51B5');
const [status, setStatus] = useState<Status>(initial?.status || 'not_started');
const [thumbPath, setThumbPath] = useState<string | undefined>(initial?.thumbnail || undefined);
const [thumbPreview, setThumbPreview] = useState<string | undefined>(undefined);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string> | null>(null);
const [instructorQuery, setInstructorQuery] = useState('');
const [instructorOptions, setInstructorOptions] = useState<Instructor[]>([]);
const [loadingInstructors, setLoadingInstructors] = useState(false);
const durationString = useMemo(() => {
const parts: string[] = [];
if (hours && hours > 0) parts.push(`${hours}h`);
if (minutes && minutes > 0) parts.push(`${minutes}m`);
return parts.join(' ');
}, [hours, minutes]);
useEffect(() => {
const ctrl = new AbortController();
setLoadingInstructors(true);
(async () => {
try {
const res = await fetch(`/api/instructors${instructorQuery ? `?q=${encodeURIComponent(instructorQuery)}` : ''}`, { signal: ctrl.signal });
const json = await res.json();
setInstructorOptions(json?.items || []);
} catch {
// ignore
} finally {
setLoadingInstructors(false);
}
})();
return () => ctrl.abort();
}, [instructorQuery]);
function validate(): Record<string, string> {
const errs: Record<string, string> = {};
if (!title || title.trim().length < 3) errs.title = 'Judul minimal 3 karakter';
if (!instructor || instructor.trim().length < 3) errs.instructor = 'Instruktur wajib dipilih';
if (!durationString) errs.duration = 'Durasi wajib';
else {
const ok = /^(\d+h|\d+m|\d+h\s\d+m)$/.test(durationString);
if (!ok) errs.duration = 'Durasi harus format seperti 6h, 30m, atau 1h 30m';
}
if (!thumbPath) errs.thumbnail = 'Thumbnail wajib';
if (color) {
const ok = /^#([A-Fa-f0-9]{6})$/.test(color.trim());
if (!ok) errs.color = 'Warna harus hex contoh #FF9800';
}
if (status && !['not_started', 'in_progress', 'completed'].includes(status)) {
errs.status = 'Status tidak valid';
}
return errs;
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
setUploadError(null);
if (!file) return;
const previewUrl = URL.createObjectURL(file);
setThumbPreview(previewUrl);
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/uploads', { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) {
const msg = json?.error || 'Upload gagal';
setUploadError(String(msg));
showToast(String(msg), 'error');
return;
}
setThumbPath(json?.path);
showToast('Thumbnail berhasil diunggah', 'success');
} catch (err: any) {
const msg = err?.message || 'Upload gagal';
setUploadError(String(msg));
showToast(String(msg), 'error');
} finally {
setUploading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) {
setErrors(errs);
showToast('Periksa kembali input Anda', 'warning');
return;
}
setErrors(null);
const payload: CourseFormValues = {
title: title.trim(),
description: description || '',
instructor: instructor.trim(),
duration: durationString,
thumbnail: thumbPath,
color,
status,
};
try {
let res: Response;
let json: any;
if (mode === 'create') {
res = await fetch('/api/courses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} else {
res = await fetch(`/api/courses/${encodeURIComponent(String(courseId))}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
json = await res.json().catch(() => null);
if (!res.ok) {
const msg = json?.error || 'Gagal menyimpan kursus';
showToast(String(msg), 'error');
setErrors({ general: String(msg) });
return;
}
showToast(mode === 'create' ? 'Kursus berhasil dibuat' : 'Perubahan berhasil disimpan', 'success');
onDone?.(json);
} catch (err: any) {
const msg = err?.message || 'Gagal menyimpan kursus';
showToast(String(msg), 'error');
setErrors({ general: String(msg) });
}
}
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4 max-w-2xl">
{errors && (
<div className="p-4 bg-yellow-50 text-yellow-700 border border-yellow-200 rounded-lg">
<ul className="list-disc ml-5 text-sm">
{Object.entries(errors).map(([k, v]) => (
<li key={k}>{v}</li>
))}
</ul>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Judul</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Deskripsi</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Instruktur</label>
<div className="flex items-center gap-2 mb-2">
<input
type="text"
placeholder="Cari instruktur..."
value={instructorQuery}
onChange={e => setInstructorQuery(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{loadingInstructors && <span className="text-sm text-gray-500">Memuat...</span>}
</div>
<select
value={instructor}
onChange={e => setInstructor(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="" disabled>Pilih instruktur</option>
{instructorOptions.map((i) => (
<option key={i.id} value={i.name}>{i.name}</option>
))}
</select>
{errors?.instructor && <p className="text-xs text-red-600 mt-1">{errors.instructor}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Durasi</label>
<div className="flex items-center gap-2">
<div className="flex flex-col">
<input type="number" min={0} max={24} value={hours} onChange={e => setHours(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg" />
<span className="text-xs text-gray-500">Jam (024)</span>
</div>
<div className="flex flex-col">
<input type="number" min={0} max={59} value={minutes} onChange={e => setMinutes(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg" />
<span className="text-xs text-gray-500">Menit (059)</span>
</div>
</div>
{errors?.duration && <p className="text-xs text-red-600 mt-1">{errors.duration}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Warna Tema</label>
<div className="flex items-center gap-3">
<input type="color" value={color} onChange={e => setColor(e.target.value)} className="h-10 w-16 p-1 border rounded" />
<input type="text" value={color} onChange={e => setColor(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg" />
</div>
{errors?.color && <p className="text-xs text-red-600 mt-1">{errors.color}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Thumbnail</label>
<div className="flex items-center gap-4">
<input type="file" accept="image/*" onChange={handleFileChange} className="flex-1" />
{uploading && <span className="text-sm text-gray-500">Mengunggah...</span>}
</div>
{(thumbPreview || thumbPath) && (
<div className="mt-3">
<img src={thumbPreview || thumbPath} alt="preview" className="w-48 h-28 object-cover rounded border" />
{thumbPath && <p className="text-xs text-gray-500 mt-1">Disimpan: {thumbPath}</p>}
</div>
)}
{uploadError && <p className="text-xs text-red-600 mt-1">{uploadError}</p>}
{errors?.thumbnail && <p className="text-xs text-red-600 mt-1">{errors.thumbnail}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status Kursus</label>
<select value={status} onChange={e => setStatus(e.target.value as Status)} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
<option value="not_started">Belum Mulai</option>
<option value="in_progress">Berlangsung</option>
<option value="completed">Selesai</option>
</select>
</div>
<div className="flex items-center justify-end gap-2">
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{mode === 'create' ? 'Buat Kursus' : 'Simpan'}
</button>
</div>
</form>
);
}