diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..7bc5d27 --- /dev/null +++ b/backend/src/server.js @@ -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 }; +} \ No newline at end of file diff --git a/src/app/admin/courses/[id]/edit/page.tsx b/src/app/admin/courses/[id]/edit/page.tsx new file mode 100644 index 0000000..a0b4f84 --- /dev/null +++ b/src/app/admin/courses/[id]/edit/page.tsx @@ -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(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 ( +
+
+
+

Edit Kursus

+

ID: {courseId}

+
+ Kembali +
+ {loading &&
Memuat data kursus...
} + {!loading && initial && ( + setTimeout(() => router.push(`/admin/courses/${courseId}`), 600)} /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/courses/create/page.tsx b/src/app/admin/courses/create/page.tsx new file mode 100644 index 0000000..a915053 --- /dev/null +++ b/src/app/admin/courses/create/page.tsx @@ -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 ( +
+
+
+

Buat Kursus Baru

+

Isi detail kursus kemudian simpan

+
+ Kembali +
+ setTimeout(() => router.push('/admin/courses'), 600)} /> +
+ ); +} \ No newline at end of file diff --git a/src/app/api/courses/[courseId]/route.ts b/src/app/api/courses/[courseId]/route.ts new file mode 100644 index 0000000..8db6eb4 --- /dev/null +++ b/src/app/api/courses/[courseId]/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/courses/route.ts b/src/app/api/courses/route.ts new file mode 100644 index 0000000..ff08956 --- /dev/null +++ b/src/app/api/courses/route.ts @@ -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 }); + } +} diff --git a/src/app/api/instructors/route.ts b/src/app/api/instructors/route.ts new file mode 100644 index 0000000..31e2bd8 --- /dev/null +++ b/src/app/api/instructors/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts new file mode 100644 index 0000000..6f5341b --- /dev/null +++ b/src/app/api/uploads/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/components/admin/CourseForm.tsx b/src/components/admin/CourseForm.tsx new file mode 100644 index 0000000..fb14ae9 --- /dev/null +++ b/src/components/admin/CourseForm.tsx @@ -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 = /(?\d+)h/.exec(initial.duration || ''); + return hMatch ? Number(hMatch.groups?.h) : 0; + }); + const [minutes, setMinutes] = useState(() => { + if (!initial?.duration) return 0; + const mMatch = /(?\d+)m/.exec(initial.duration || ''); + return mMatch ? Number(mMatch.groups?.m) : 0; + }); + const [color, setColor] = useState(initial?.color || '#3F51B5'); + const [status, setStatus] = useState(initial?.status || 'not_started'); + + const [thumbPath, setThumbPath] = useState(initial?.thumbnail || undefined); + const [thumbPreview, setThumbPreview] = useState(undefined); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + const [errors, setErrors] = useState | null>(null); + + const [instructorQuery, setInstructorQuery] = useState(''); + const [instructorOptions, setInstructorOptions] = useState([]); + 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 { + const errs: Record = {}; + 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) { + 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 ( +
+ {errors && ( +
+
    + {Object.entries(errors).map(([k, v]) => ( +
  • {v}
  • + ))} +
+
+ )} + +
+ + 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 + /> +
+ +
+ +