mirror of
https://github.com/th30d4y/OpenLearnX.git
synced 2026-05-26 19:26:33 +00:00
feat: unify real activity tracking, admin monitoring, and error UX
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 401</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Unauthorized</h1>
|
||||
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
You need to log in to access this page.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 403</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Forbidden</h1>
|
||||
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
You do not have permission to access this page.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export default function AdaptiveQuizPage() {
|
||||
|
||||
if (!quizStarted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
|
||||
<div className="max-w-2xl mx-auto p-6 text-center">
|
||||
<div className="mb-8">
|
||||
<Brain className="h-16 w-16 text-purple-400 mx-auto mb-4" />
|
||||
@@ -209,7 +209,7 @@ export default function AdaptiveQuizPage() {
|
||||
|
||||
if (quizCompleted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="text-center mb-8">
|
||||
<Award className="h-16 w-16 text-yellow-400 mx-auto mb-4" />
|
||||
@@ -252,16 +252,16 @@ export default function AdaptiveQuizPage() {
|
||||
)}
|
||||
|
||||
{sessionStats && (
|
||||
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
||||
<h3 className="text-xl font-bold mb-4">Performance by Difficulty</h3>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6">
|
||||
<h3 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Performance by Difficulty</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{Object.entries(sessionStats.difficulty_breakdown).map(([difficulty, stats]) => (
|
||||
<div key={difficulty} className="bg-gray-900 p-4 rounded">
|
||||
<div key={difficulty} className="bg-gray-50 dark:bg-gray-900 p-4 rounded">
|
||||
<div className={`px-2 py-1 rounded text-xs font-medium mb-2 ${getDifficultyColor(difficulty)}`}>
|
||||
{difficulty.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-lg font-bold">{stats.accuracy}%</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{stats.accuracy}%</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{stats.correct}/{stats.questions} questions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Edit, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
type Course = {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
description: string
|
||||
difficulty: string
|
||||
mentor: string
|
||||
video_url: string
|
||||
students: number
|
||||
}
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
export default function AdminCoursesPage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [courses, setCourses] = useState<Course[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editing, setEditing] = useState<Course | null>(null)
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchCourses = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/courses`, { headers: headers() })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setCourses(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setCourses([])
|
||||
}
|
||||
} catch {
|
||||
setCourses([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveCourse = async (payload: Partial<Course>, courseId?: string) => {
|
||||
const url = courseId ? `${API_BASE}/api/admin/courses/${courseId}` : `${API_BASE}/api/admin/courses`
|
||||
const method = courseId ? "PUT" : "POST"
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (resp.ok) {
|
||||
setShowAdd(false)
|
||||
setEditing(null)
|
||||
await fetchCourses()
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({ error: "Operation failed" }))
|
||||
alert(err.error || "Operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCourse = async (courseId: string) => {
|
||||
if (!confirm("Delete this course and related modules/lessons?")) return
|
||||
const resp = await fetch(`${API_BASE}/api/admin/courses/${courseId}`, {
|
||||
method: "DELETE",
|
||||
headers: headers(),
|
||||
})
|
||||
if (resp.ok) {
|
||||
await fetchCourses()
|
||||
} else {
|
||||
alert("Failed to delete course")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchCourses()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading course management...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Course Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">Manage real courses from database records.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Course
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Title</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Subject</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Difficulty</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Mentor</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Students</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-4 py-4 text-sm text-gray-600" colSpan={6}>Loading courses...</td>
|
||||
</tr>
|
||||
) : courses.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-4 text-sm text-gray-500" colSpan={6}>No courses found.</td>
|
||||
</tr>
|
||||
) : (
|
||||
courses.map((course) => (
|
||||
<tr key={course.id}>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">{course.title}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.subject}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.difficulty}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{course.mentor}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{Number(course.students || 0).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditing(course)}
|
||||
className="rounded p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-950/30"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteCourse(course.id)}
|
||||
className="rounded p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showAdd || editing) && (
|
||||
<CourseFormModal
|
||||
title={editing ? "Edit Course" : "Add Course"}
|
||||
initialData={editing || undefined}
|
||||
onClose={() => {
|
||||
setShowAdd(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSubmit={(payload) => saveCourse(payload, editing?.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseFormModal({
|
||||
title,
|
||||
initialData,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string
|
||||
initialData?: Partial<Course>
|
||||
onClose: () => void
|
||||
onSubmit: (payload: Partial<Course>) => Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<Partial<Course>>({
|
||||
title: initialData?.title || "",
|
||||
subject: initialData?.subject || "",
|
||||
description: initialData?.description || "",
|
||||
difficulty: initialData?.difficulty || "Beginner",
|
||||
mentor: initialData?.mentor || "",
|
||||
video_url: initialData?.video_url || "",
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
await onSubmit(form)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
|
||||
<div className="w-full max-w-xl rounded-xl border border-gray-200 bg-white p-5 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<input
|
||||
required
|
||||
placeholder="Title"
|
||||
value={form.title || ""}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
required
|
||||
placeholder="Subject"
|
||||
value={form.subject || ""}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
placeholder="Description"
|
||||
value={form.description || ""}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<input
|
||||
placeholder="Difficulty"
|
||||
value={form.difficulty || ""}
|
||||
onChange={(e) => setForm({ ...form, difficulty: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
placeholder="Mentor"
|
||||
value={form.mentor || ""}
|
||||
onChange={(e) => setForm({ ...form, mentor: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Video URL"
|
||||
value={form.video_url || ""}
|
||||
onChange={(e) => setForm({ ...form, video_url: e.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="rounded-md bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-60">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
type CollectionInfo = { name: string; count: number }
|
||||
type DocumentRow = Record<string, unknown>
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
export default function AdminDatabasePage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [collections, setCollections] = useState<CollectionInfo[]>([])
|
||||
const [selectedCollection, setSelectedCollection] = useState<string>("")
|
||||
const [documents, setDocuments] = useState<DocumentRow[]>([])
|
||||
const [search, setSearch] = useState("")
|
||||
const [loadingCollections, setLoadingCollections] = useState(false)
|
||||
const [loadingDocuments, setLoadingDocuments] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 1 })
|
||||
const [createJson, setCreateJson] = useState('{\n "key": "value"\n}')
|
||||
const [editDocId, setEditDocId] = useState("")
|
||||
const [editJson, setEditJson] = useState("{}")
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchOverview = async () => {
|
||||
setLoadingCollections(true)
|
||||
setMessage("")
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/database/overview`, { headers: headers() })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
const list = Array.isArray(data.collections) ? data.collections : []
|
||||
setCollections(list)
|
||||
if (list.length > 0 && !selectedCollection) {
|
||||
setSelectedCollection(list[0].name)
|
||||
await fetchDocuments(list[0].name, 1)
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
setMessage(String(data.error || "Failed to load collections."))
|
||||
}
|
||||
} catch {
|
||||
setMessage("Network error while loading collections.")
|
||||
} finally {
|
||||
setLoadingCollections(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDocuments = async (collection: string, page = 1, nextSearch = search) => {
|
||||
if (!collection) return
|
||||
setLoadingDocuments(true)
|
||||
setMessage("")
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(pagination.limit) })
|
||||
if (nextSearch.trim()) params.set("search", nextSearch.trim())
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/database/collections/${encodeURIComponent(collection)}?${params.toString()}`, { headers: headers() })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setDocuments(Array.isArray(data.documents) ? data.documents : [])
|
||||
if (data.pagination) setPagination(data.pagination)
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
setMessage(String(data.error || "Failed to load documents."))
|
||||
setDocuments([])
|
||||
}
|
||||
} catch {
|
||||
setMessage("Network error while loading documents.")
|
||||
setDocuments([])
|
||||
} finally {
|
||||
setLoadingDocuments(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createDocument = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedCollection) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(createJson)
|
||||
const resp = await fetch(`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to create document."))
|
||||
return
|
||||
}
|
||||
|
||||
setMessage("Document created successfully.")
|
||||
await fetchOverview()
|
||||
await fetchDocuments(selectedCollection, 1)
|
||||
} catch {
|
||||
setMessage("Invalid JSON for create payload.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditDocument = (doc: DocumentRow) => {
|
||||
const id = String(doc._id || "")
|
||||
if (!id) {
|
||||
setMessage("Selected document has no _id.")
|
||||
return
|
||||
}
|
||||
|
||||
const clone = { ...doc }
|
||||
delete clone._id
|
||||
setEditDocId(id)
|
||||
setEditJson(JSON.stringify(clone, null, 2))
|
||||
}
|
||||
|
||||
const saveEditDocument = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedCollection || !editDocId) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(editJson)
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}/${encodeURIComponent(editDocId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to update document."))
|
||||
return
|
||||
}
|
||||
|
||||
setMessage("Document updated successfully.")
|
||||
setEditDocId("")
|
||||
setEditJson("{}")
|
||||
await fetchDocuments(selectedCollection, pagination.page)
|
||||
} catch {
|
||||
setMessage("Invalid JSON for update payload.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDocument = async (docId: string) => {
|
||||
if (!selectedCollection || !docId) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/api/admin/database/collections/${encodeURIComponent(selectedCollection)}/${encodeURIComponent(docId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: headers(),
|
||||
},
|
||||
)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to delete document."))
|
||||
return
|
||||
}
|
||||
|
||||
setMessage("Document deleted successfully.")
|
||||
await fetchOverview()
|
||||
await fetchDocuments(selectedCollection, 1)
|
||||
} catch {
|
||||
setMessage("Network error while deleting document.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchOverview()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading database explorer...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Database Explorer</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Browse all collections and perform create, update, and delete actions on documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<form
|
||||
onSubmit={createDocument}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Create Document</h3>
|
||||
<p className="mb-2 text-xs text-gray-600 dark:text-gray-300">Collection: {selectedCollection || "None selected"}</p>
|
||||
<textarea
|
||||
value={createJson}
|
||||
onChange={(e) => setCreateJson(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !selectedCollection}
|
||||
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving..." : "Create"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={saveEditDocument}
|
||||
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Edit Document</h3>
|
||||
<p className="mb-2 text-xs text-gray-600 dark:text-gray-300">Document ID: {editDocId || "Select a document below"}</p>
|
||||
<textarea
|
||||
value={editJson}
|
||||
onChange={(e) => setEditJson(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !selectedCollection || !editDocId}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving..." : "Update"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditDocId("")
|
||||
setEditJson("{}")
|
||||
}}
|
||||
className="rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-700 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Collections</h3>
|
||||
{loadingCollections ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{collections.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => {
|
||||
setSelectedCollection(item.name)
|
||||
fetchDocuments(item.name, 1)
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded px-3 py-2 text-sm ${
|
||||
selectedCollection === item.name
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-50 text-gray-800 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-left">{item.name}</span>
|
||||
<span className="ml-2 text-xs">{item.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-3 rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-gray-100 p-4 dark:border-gray-800">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search inside documents"
|
||||
className="min-w-[260px] flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fetchDocuments(selectedCollection, 1)}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[72vh] overflow-auto p-4">
|
||||
{loadingDocuments ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Loading documents...</p>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No documents to show.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc, idx) => (
|
||||
<div
|
||||
key={String(doc._id || idx)}
|
||||
className="rounded border border-gray-200 bg-gray-50 p-3 text-xs text-gray-800 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => startEditDocument(doc)}
|
||||
className="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteDocument(String(doc._id || ""))}
|
||||
className="rounded bg-red-700 px-2 py-1 text-white hover:bg-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-600 dark:text-gray-300">ID: {String(doc._id || "unknown")}</span>
|
||||
</div>
|
||||
<pre className="overflow-auto">{JSON.stringify(doc, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
|
||||
<span>Page {pagination.page} of {pagination.pages} • Total {pagination.total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchDocuments(selectedCollection, Math.max(1, pagination.page - 1))}
|
||||
disabled={pagination.page <= 1}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchDocuments(selectedCollection, Math.min(pagination.pages, pagination.page + 1))}
|
||||
disabled={pagination.page >= pagination.pages}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client"
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
type FirewallRule = {
|
||||
id: string
|
||||
name?: string
|
||||
ip?: string
|
||||
method?: string
|
||||
path_pattern?: string
|
||||
action?: string
|
||||
enabled?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
type RuleForm = {
|
||||
name: string
|
||||
ip: string
|
||||
method: string
|
||||
path_pattern: string
|
||||
action: "block" | "allow"
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const initialRuleForm: RuleForm = {
|
||||
name: "",
|
||||
ip: "",
|
||||
method: "",
|
||||
path_pattern: "",
|
||||
action: "block",
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
export default function AdminFirewallPage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [rules, setRules] = useState<FirewallRule[]>([])
|
||||
const [message, setMessage] = useState("")
|
||||
const [form, setForm] = useState<RuleForm>(initialRuleForm)
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchRules = async () => {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules`, { headers: headers() })
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
setRules([])
|
||||
setMessage(String(data.error || "Failed to load firewall rules."))
|
||||
return
|
||||
}
|
||||
|
||||
setRules(Array.isArray(data.rules) ? data.rules : [])
|
||||
} catch {
|
||||
setRules([])
|
||||
setMessage("Network error while loading firewall rules.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRule = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
method: form.method.trim().toUpperCase(),
|
||||
path_pattern: form.path_pattern.trim(),
|
||||
action: form.action,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to create firewall rule."))
|
||||
return
|
||||
}
|
||||
|
||||
setForm(initialRuleForm)
|
||||
setMessage("Firewall rule created.")
|
||||
await fetchRules()
|
||||
} catch {
|
||||
setMessage("Network error while creating firewall rule.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRule = async (ruleId: string) => {
|
||||
if (!ruleId) return
|
||||
|
||||
setMessage("")
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/firewall/rules/${encodeURIComponent(ruleId)}`, {
|
||||
method: "DELETE",
|
||||
headers: headers(),
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to delete firewall rule."))
|
||||
return
|
||||
}
|
||||
|
||||
setMessage("Firewall rule deleted.")
|
||||
await fetchRules()
|
||||
} catch {
|
||||
setMessage("Network error while deleting firewall rule.")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchRules()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading firewall manager...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Manual Firewall</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Add or remove manual allow/block rules by IP, method, and path pattern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={createRule}
|
||||
className="grid gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900 lg:grid-cols-6"
|
||||
>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Rule name"
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={form.ip}
|
||||
onChange={(e) => setForm((p) => ({ ...p, ip: e.target.value }))}
|
||||
placeholder="IP (optional)"
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={form.method}
|
||||
onChange={(e) => setForm((p) => ({ ...p, method: e.target.value }))}
|
||||
placeholder="Method (GET/POST...)"
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={form.path_pattern}
|
||||
onChange={(e) => setForm((p) => ({ ...p, path_pattern: e.target.value }))}
|
||||
placeholder="Path pattern (optional)"
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={form.action}
|
||||
onChange={(e) => setForm((p) => ({ ...p, action: e.target.value as "block" | "allow" }))}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="block">block</option>
|
||||
<option value="allow">allow</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Adding..." : "Add Rule"}
|
||||
</button>
|
||||
|
||||
<label className="col-span-full inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm((p) => ({ ...p, enabled: e.target.checked }))}
|
||||
/>
|
||||
Rule enabled
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Active Rules</h2>
|
||||
{message ? <p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">IP</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Method</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Path Pattern</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Action</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Enabled</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Created</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Loading rules...
|
||||
</td>
|
||||
</tr>
|
||||
) : rules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-4 text-sm text-gray-500">
|
||||
No firewall rules configured.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="px-3 py-2 text-xs text-gray-800 dark:text-gray-100">{rule.name || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.ip || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.method || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.path_pattern || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.action || "block"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{rule.enabled ? "true" : "false"}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(rule.created_at || "-")}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
className="rounded bg-red-700 px-2 py-1 text-white hover:bg-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useEffect, useMemo, useState, type ComponentType } from "react"
|
||||
import { BarChart3, BookOpen, Database, FileText, LayoutDashboard, LogOut, Shield, Users } from "lucide-react"
|
||||
|
||||
type NavItem = {
|
||||
href: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/courses", label: "Courses", icon: BookOpen },
|
||||
{ href: "/admin/users", label: "Users", icon: Users },
|
||||
{ href: "/admin/logs", label: "Logs", icon: FileText },
|
||||
{ href: "/admin/reports", label: "Reports", icon: BarChart3 },
|
||||
{ href: "/admin/database", label: "Database", icon: Database },
|
||||
{ href: "/admin/firewall", label: "Firewall", icon: Shield },
|
||||
]
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const isLoginPage = useMemo(() => pathname === "/admin/login", [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setReady(true)
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
}
|
||||
|
||||
if (!ready) return null
|
||||
if (isLoginPage) return <>{children}</>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||
<div className="flex w-full">
|
||||
<aside className="sticky top-0 h-screen w-72 shrink-0 border-r border-gray-200 bg-white/90 p-5 backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/90">
|
||||
<div className="mb-6 border-b border-gray-200 pb-4 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">OpenLearnX Admin</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Professional control panel</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-blue-600 text-white"
|
||||
: "text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/40">
|
||||
<div className="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<p className="text-xs font-medium">Live Data Enabled</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
Stats, logs, and actions are loaded directly from backend collections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 flex-1 p-5 lg:p-7">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export default function AdminLogin() {
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded text-sm">
|
||||
⚠️ {error}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function AdminLogin() {
|
||||
Authenticating...
|
||||
</div>
|
||||
) : (
|
||||
'🔐 Login to Admin Panel'
|
||||
'Login to Admin Panel'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
@@ -162,7 +162,7 @@ export default function AdminLogin() {
|
||||
<div className="mt-6 pt-4 border-t border-gray-100">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
🔒 Secure access only - Contact administrator for credentials
|
||||
Secure access only - Contact administrator for credentials
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@ export default function AdminLogin() {
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Welcome back, <span className="font-medium text-gray-700">5t4l1n</span>! 👋
|
||||
Welcome back, <span className="font-medium text-gray-700">5t4l1n</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
type AdminLog = {
|
||||
id: string
|
||||
timestamp: string
|
||||
event_type: string
|
||||
action: string
|
||||
status_code: number
|
||||
severity: string
|
||||
method: string
|
||||
ip: string
|
||||
path: string
|
||||
user_agent?: string
|
||||
metadata?: Record<string, unknown>
|
||||
request_body?: unknown
|
||||
response_body?: unknown
|
||||
usage?: Record<string, unknown>
|
||||
query?: Record<string, unknown>
|
||||
duration_ms?: number
|
||||
origin?: string
|
||||
}
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [logs, setLogs] = useState<AdminLog[]>([])
|
||||
const [selectedLog, setSelectedLog] = useState<AdminLog | null>(null)
|
||||
|
||||
const safeJson = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return "No data"
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setMessage("Copied to clipboard")
|
||||
} catch {
|
||||
setMessage("Copy failed")
|
||||
}
|
||||
}
|
||||
|
||||
const selectedRequestData = selectedLog
|
||||
? selectedLog.request_body
|
||||
?? (selectedLog.metadata && (selectedLog.metadata as any).request_body)
|
||||
?? (selectedLog.metadata && (selectedLog.metadata as any).request_details)
|
||||
?? selectedLog.query
|
||||
?? null
|
||||
: null
|
||||
|
||||
const selectedResponseData = selectedLog
|
||||
? selectedLog.response_body
|
||||
?? (selectedLog.metadata && (selectedLog.metadata as any).response_body)
|
||||
?? (selectedLog.metadata && (selectedLog.metadata as any).response_details)
|
||||
?? null
|
||||
: null
|
||||
|
||||
const selectedUsageData = selectedLog
|
||||
? selectedLog.usage
|
||||
?? (selectedLog.metadata && (selectedLog.metadata as any).usage)
|
||||
?? {
|
||||
duration_ms: selectedLog.duration_ms ?? 0,
|
||||
note: "Usage metrics not captured for this log entry",
|
||||
}
|
||||
: null
|
||||
const [filters, setFilters] = useState({
|
||||
event_type: "",
|
||||
severity: "",
|
||||
status_code: "",
|
||||
search: "",
|
||||
})
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 50, total: 0, pages: 1 })
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchLogs = async (page = 1, nextFilters = filters) => {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
const params = new URLSearchParams()
|
||||
params.set("page", String(page))
|
||||
params.set("limit", String(pagination.limit))
|
||||
if (nextFilters.event_type) params.set("event_type", nextFilters.event_type)
|
||||
if (nextFilters.severity) params.set("severity", nextFilters.severity)
|
||||
if (nextFilters.status_code) params.set("status_code", nextFilters.status_code)
|
||||
if (nextFilters.search) params.set("search", nextFilters.search)
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/logs?${params.toString()}`, { headers: headers() })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setLogs(Array.isArray(data.logs) ? data.logs : [])
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination)
|
||||
}
|
||||
} else {
|
||||
setLogs([])
|
||||
}
|
||||
} catch {
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerDownload = (content: string, filename: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const exportLogs = async (format: "json" | "csv") => {
|
||||
setExporting(true)
|
||||
setMessage("")
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set("type", "logs")
|
||||
params.set("format", format)
|
||||
params.set("limit", "5000")
|
||||
if (filters.event_type) params.set("event_type", filters.event_type)
|
||||
if (filters.severity) params.set("severity", filters.severity)
|
||||
if (filters.status_code) params.set("status_code", filters.status_code)
|
||||
if (filters.search) params.set("search", filters.search)
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/admin/reports/export?${params.toString()}`, { headers: headers() })
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to export logs."))
|
||||
return
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[.:]/g, "-")
|
||||
if (format === "json") {
|
||||
triggerDownload(JSON.stringify(data, null, 2), `admin-logs-${stamp}.json`, "application/json")
|
||||
} else {
|
||||
triggerDownload(String(data.content || ""), `admin-logs-${stamp}.csv`, "text/csv")
|
||||
}
|
||||
|
||||
setMessage(`Logs exported as ${format.toUpperCase()}.`)
|
||||
} catch {
|
||||
setMessage("Network error while exporting logs.")
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchLogs(1)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading logs...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Security and Activity Logs</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Filter authentication, access-control, suspicious payload, and admin activity events.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => exportLogs("json")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
Export Logs JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportLogs("csv")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-emerald-700 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-60"
|
||||
>
|
||||
Export Logs CSV
|
||||
</button>
|
||||
</div>
|
||||
{message ? <p className="mt-2 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-gray-100 p-4 md:grid-cols-6 dark:border-gray-800">
|
||||
<input
|
||||
placeholder="Search action, path, IP"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
className="md:col-span-2 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={filters.event_type}
|
||||
onChange={(e) => setFilters({ ...filters, event_type: e.target.value })}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">All Event Types</option>
|
||||
<option value="admin_panel">Admin Panel</option>
|
||||
<option value="admin_panel_visit">Admin Visit</option>
|
||||
<option value="signin">Sign In</option>
|
||||
<option value="signup">Sign Up</option>
|
||||
<option value="course_join">Course Join</option>
|
||||
<option value="attendance">Attendance</option>
|
||||
<option value="forbidden_access">403 Forbidden</option>
|
||||
<option value="suspicious_payload">Suspicious Payload</option>
|
||||
</select>
|
||||
<select
|
||||
value={filters.severity}
|
||||
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">All Severity</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="Status code"
|
||||
value={filters.status_code}
|
||||
onChange={(e) => setFilters({ ...filters, status_code: e.target.value })}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchLogs(1)}
|
||||
className="w-full rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reset = { event_type: "", severity: "", status_code: "", search: "" }
|
||||
setFilters(reset)
|
||||
fetchLogs(1, reset)
|
||||
}}
|
||||
className="w-full rounded-md bg-gray-200 px-3 py-2 text-sm font-medium text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Time</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Event</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Action</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Status</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">IP</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium uppercase text-gray-500">Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-4 py-4 text-sm text-gray-600" colSpan={6}>Loading logs...</td>
|
||||
</tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-4 text-sm text-gray-500" colSpan={6}>No logs found for selected filters.</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="cursor-pointer hover:bg-blue-50 dark:hover:bg-gray-800/60"
|
||||
title="Click to view request and response details"
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{new Date(log.timestamp).toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-xs font-medium text-gray-900 dark:text-white">{log.event_type}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
<span className={`rounded px-2 py-1 ${log.status_code >= 400 ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}>
|
||||
{log.status_code}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.ip}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-300">{log.path}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
|
||||
<span>
|
||||
Page {pagination.page} of {pagination.pages} • Total {pagination.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchLogs(Math.max(1, pagination.page - 1))}
|
||||
disabled={pagination.page <= 1}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchLogs(Math.min(pagination.pages, pagination.page + 1))}
|
||||
disabled={pagination.page >= pagination.pages}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLog ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-4xl rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 p-4 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Log Request and Response Details</h2>
|
||||
<button
|
||||
onClick={() => setSelectedLog(null)}
|
||||
className="rounded-md bg-gray-200 px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[75vh] space-y-4 overflow-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Event</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.event_type}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Action</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.action}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Path</p>
|
||||
<p className="mt-1 text-sm break-all text-gray-900 dark:text-white">{selectedLog.path}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Method and Status</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-white">{selectedLog.method} {selectedLog.status_code}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Duration</p>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{selectedLog.duration_ms ?? (selectedLog.metadata && (selectedLog.metadata as any).duration_ms) ?? 0} ms
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Client</p>
|
||||
<p className="mt-1 text-sm break-all text-gray-900 dark:text-white">{selectedLog.ip}</p>
|
||||
<p className="mt-1 text-xs break-all text-gray-600 dark:text-gray-300">{selectedLog.user_agent || "Unknown user agent"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Request Body</p>
|
||||
<button
|
||||
onClick={() => copyText(safeJson(selectedRequestData))}
|
||||
className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
|
||||
{safeJson(selectedRequestData)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Response Body</p>
|
||||
<button
|
||||
onClick={() => copyText(safeJson(selectedResponseData))}
|
||||
className="rounded bg-emerald-600 px-2 py-1 text-xs text-white hover:bg-emerald-700"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
|
||||
{safeJson(selectedResponseData)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Usage Monitoring</p>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
|
||||
{safeJson(selectedUsageData)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase text-gray-500">Full Metadata</p>
|
||||
<button
|
||||
onClick={() => copyText(safeJson(selectedLog.metadata ?? {}))}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-xs text-white hover:bg-gray-800"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-gray-50 p-3 text-sm leading-6 text-gray-800 dark:bg-gray-800 dark:text-gray-100">
|
||||
{safeJson(selectedLog.metadata ?? {})}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+262
-1140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
type UsageReport = Record<string, string | number>
|
||||
type SecurityReport = {
|
||||
generated_at?: string
|
||||
login_attempts?: number
|
||||
suspicious_events?: number
|
||||
error_events?: number
|
||||
blocked_events?: number
|
||||
top_ips?: Array<{ ip: string; count: number }>
|
||||
}
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [usageReport, setUsageReport] = useState<UsageReport>({})
|
||||
const [securityReport, setSecurityReport] = useState<SecurityReport>({})
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchReports = async () => {
|
||||
setLoading(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const [usageResp, securityResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/admin/reports/usage`, { headers: headers() }),
|
||||
fetch(`${API_BASE}/api/admin/reports/security`, { headers: headers() }),
|
||||
])
|
||||
|
||||
const usageData = await usageResp.json().catch(() => ({}))
|
||||
const securityData = await securityResp.json().catch(() => ({}))
|
||||
|
||||
if (!usageResp.ok || !securityResp.ok) {
|
||||
setMessage(String(usageData.error || securityData.error || "Failed to fetch reports."))
|
||||
return
|
||||
}
|
||||
|
||||
setUsageReport(usageData.report || {})
|
||||
setSecurityReport(securityData.report || {})
|
||||
} catch {
|
||||
setMessage("Network error while fetching reports.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerDownload = (content: string, filename: string, mimeType: string) => {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const exportReport = async (reportType: "usage" | "security", format: "json" | "csv") => {
|
||||
setExporting(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/api/admin/reports/export?type=${encodeURIComponent(reportType)}&format=${encodeURIComponent(format)}`,
|
||||
{ headers: headers() },
|
||||
)
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Export failed."))
|
||||
return
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[.:]/g, "-")
|
||||
if (format === "json") {
|
||||
triggerDownload(JSON.stringify(data, null, 2), `${reportType}-report-${stamp}.json`, "application/json")
|
||||
} else {
|
||||
triggerDownload(String(data.content || "key,value\n"), `${reportType}-report-${stamp}.csv`, "text/csv")
|
||||
}
|
||||
|
||||
setMessage(`${reportType} report exported as ${format.toUpperCase()}.`)
|
||||
} catch {
|
||||
setMessage("Network error while exporting report.")
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchReports()
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading reports...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">Reports and Analytics</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Usage and security reporting with downloadable JSON and CSV exports.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchReports()}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Refreshing..." : "Refresh Reports"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportReport("usage", "json")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
Export Usage JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportReport("usage", "csv")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-emerald-700 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-800 disabled:opacity-60"
|
||||
>
|
||||
Export Usage CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportReport("security", "json")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
>
|
||||
Export Security JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportReport("security", "csv")}
|
||||
disabled={exporting}
|
||||
className="rounded-md bg-indigo-700 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-800 disabled:opacity-60"
|
||||
>
|
||||
Export Security CSV
|
||||
</button>
|
||||
</div>
|
||||
{message ? <p className="mt-3 text-sm text-gray-700 dark:text-gray-200">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Usage Report</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Loading usage report...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(usageReport).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
|
||||
<span className="text-gray-600 dark:text-gray-300">{key}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(usageReport).length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No usage data available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500">Security Report</h2>
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Loading security report...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
|
||||
<span className="text-gray-600 dark:text-gray-300">Login attempts:</span>{" "}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.login_attempts || 0}</span>
|
||||
</div>
|
||||
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
|
||||
<span className="text-gray-600 dark:text-gray-300">Suspicious events:</span>{" "}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.suspicious_events || 0}</span>
|
||||
</div>
|
||||
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
|
||||
<span className="text-gray-600 dark:text-gray-300">Error events:</span>{" "}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.error_events || 0}</span>
|
||||
</div>
|
||||
<div className="rounded border border-gray-100 px-3 py-2 text-sm dark:border-gray-800">
|
||||
<span className="text-gray-600 dark:text-gray-300">Blocked by firewall:</span>{" "}
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{securityReport.blocked_events || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-100 p-3 text-sm dark:border-gray-800">
|
||||
<p className="mb-2 font-medium text-gray-900 dark:text-gray-100">Top Source IPs</p>
|
||||
<div className="space-y-1">
|
||||
{(securityReport.top_ips || []).map((entry) => (
|
||||
<div key={`${entry.ip}-${entry.count}`} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-600 dark:text-gray-300">{entry.ip}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{entry.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{(securityReport.top_ips || []).length === 0 ? (
|
||||
<p className="text-xs text-gray-500">No IP analytics available.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
"use client"
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
type UserDoc = Record<string, unknown>
|
||||
|
||||
type UserFormState = {
|
||||
email: string
|
||||
username: string
|
||||
name: string
|
||||
wallet_address: string
|
||||
role: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const API_BASE = "http://127.0.0.1:5000"
|
||||
|
||||
const initialUserForm: UserFormState = {
|
||||
email: "",
|
||||
username: "",
|
||||
name: "",
|
||||
wallet_address: "",
|
||||
role: "student",
|
||||
password: "",
|
||||
}
|
||||
|
||||
const getUserId = (user: UserDoc) => String(user._id || user.id || "")
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<string>("")
|
||||
const [users, setUsers] = useState<UserDoc[]>([])
|
||||
const [selectedUser, setSelectedUser] = useState<UserDoc | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [roleFilter, setRoleFilter] = useState("all")
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, pages: 1 })
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
const [createForm, setCreateForm] = useState<UserFormState>(initialUserForm)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editId, setEditId] = useState("")
|
||||
const [editForm, setEditForm] = useState<UserFormState>(initialUserForm)
|
||||
|
||||
const getToken = () => localStorage.getItem("admin_token")
|
||||
|
||||
const headers = () => {
|
||||
const token = getToken()
|
||||
return token
|
||||
? { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
||||
: { "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/test`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
localStorage.removeItem("admin_token")
|
||||
router.push("/admin/login")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const fetchUsers = async (page = 1, nextSearch = search, nextStatus = statusFilter, nextRole = roleFilter) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(pagination.limit),
|
||||
})
|
||||
|
||||
if (nextSearch.trim()) params.set("search", nextSearch.trim())
|
||||
if (nextStatus !== "all") params.set("status", nextStatus)
|
||||
if (nextRole !== "all") params.set("role", nextRole)
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/users?${params.toString()}`, { headers: headers() })
|
||||
if (!resp.ok) {
|
||||
setUsers([])
|
||||
setMessage("Failed to load users.")
|
||||
return
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
setUsers(Array.isArray(data.users) ? data.users : [])
|
||||
if (data.pagination) setPagination(data.pagination)
|
||||
setMessage("")
|
||||
} catch {
|
||||
setUsers([])
|
||||
setMessage("Network error while loading users.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: createForm.email.trim(),
|
||||
username: createForm.username.trim(),
|
||||
name: createForm.name.trim(),
|
||||
wallet_address: createForm.wallet_address.trim(),
|
||||
role: createForm.role,
|
||||
}
|
||||
|
||||
if (createForm.password.trim()) payload.password = createForm.password
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/users`, {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to create user."))
|
||||
return
|
||||
}
|
||||
|
||||
setCreateForm(initialUserForm)
|
||||
setMessage("User created successfully.")
|
||||
await fetchUsers(1)
|
||||
} catch {
|
||||
setMessage("Network error while creating user.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (user: UserDoc) => {
|
||||
setEditMode(true)
|
||||
setEditId(getUserId(user))
|
||||
setEditForm({
|
||||
email: String(user.email || ""),
|
||||
username: String(user.username || ""),
|
||||
name: String(user.name || ""),
|
||||
wallet_address: String(user.wallet_address || ""),
|
||||
role: String(user.role || "student"),
|
||||
password: "",
|
||||
})
|
||||
}
|
||||
|
||||
const submitEdit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editId) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
email: editForm.email.trim(),
|
||||
username: editForm.username.trim(),
|
||||
name: editForm.name.trim(),
|
||||
wallet_address: editForm.wallet_address.trim(),
|
||||
role: editForm.role,
|
||||
}
|
||||
if (editForm.password.trim()) payload.password = editForm.password
|
||||
|
||||
const resp = await fetch(`${API_BASE}/api/admin/users/${editId}`, {
|
||||
method: "PUT",
|
||||
headers: headers(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Failed to update user."))
|
||||
return
|
||||
}
|
||||
|
||||
setEditMode(false)
|
||||
setEditId("")
|
||||
setEditForm(initialUserForm)
|
||||
setMessage("User updated successfully.")
|
||||
await fetchUsers(pagination.page)
|
||||
} catch {
|
||||
setMessage("Network error while updating user.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const quickAction = async (
|
||||
userId: string,
|
||||
action: "suspend" | "ban" | "activate" | "delete" | "reset-password",
|
||||
role?: string,
|
||||
) => {
|
||||
if (!userId) return
|
||||
|
||||
setActionLoadingId(`${userId}:${action}`)
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
let endpoint = `${API_BASE}/api/admin/users/${userId}/${action}`
|
||||
let method: "POST" | "DELETE" = "POST"
|
||||
let body: string | undefined
|
||||
|
||||
if (action === "delete") method = "DELETE"
|
||||
if (action === "reset-password") body = JSON.stringify({ new_password: "TempPass@123" })
|
||||
if (action === "suspend" || action === "ban" || action === "activate") {
|
||||
endpoint = `${API_BASE}/api/admin/users/${userId}/status`
|
||||
const statusMap: Record<string, string> = { suspend: "suspended", ban: "banned", activate: "active" }
|
||||
body = JSON.stringify({ status: statusMap[action] })
|
||||
}
|
||||
if (role) {
|
||||
endpoint = `${API_BASE}/api/admin/users/${userId}/role`
|
||||
body = JSON.stringify({ role })
|
||||
}
|
||||
|
||||
const resp = await fetch(endpoint, {
|
||||
method,
|
||||
headers: headers(),
|
||||
body,
|
||||
})
|
||||
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
setMessage(String(data.error || "Action failed."))
|
||||
return
|
||||
}
|
||||
|
||||
setMessage(role ? `Role updated to ${role}.` : `User action ${action} completed.`)
|
||||
await fetchUsers(pagination.page)
|
||||
} catch {
|
||||
setMessage("Network error while running action.")
|
||||
} finally {
|
||||
setActionLoadingId("")
|
||||
}
|
||||
}
|
||||
|
||||
const roleSet = useMemo(() => {
|
||||
const roles = new Set<string>()
|
||||
for (const user of users) {
|
||||
const value = String(user.role || "").trim()
|
||||
if (value) roles.add(value)
|
||||
}
|
||||
return Array.from(roles)
|
||||
}, [users])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const ok = await ensureAuth()
|
||||
if (!ok) return
|
||||
setReady(true)
|
||||
await fetchUsers(1)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-gray-600 dark:text-gray-300">Loading users...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">User Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Manage accounts, roles, access status, and student progress from real database records.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<form
|
||||
onSubmit={handleCreateUser}
|
||||
className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Create User</h2>
|
||||
<input
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="Email"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={createForm.username}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
|
||||
placeholder="Username"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={createForm.wallet_address}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, wallet_address: e.target.value }))}
|
||||
placeholder="Wallet address"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="student">student</option>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<input
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
|
||||
placeholder="Password (optional)"
|
||||
type="password"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving..." : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
onSubmit={submitEdit}
|
||||
className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Edit User</h2>
|
||||
{!editMode ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Select a user from the table to edit details.</p>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="Email"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={editForm.username}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, username: e.target.value }))}
|
||||
placeholder="Username"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Full name"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<input
|
||||
value={editForm.wallet_address}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, wallet_address: e.target.value }))}
|
||||
placeholder="Wallet address"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={editForm.role}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="student">student</option>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<input
|
||||
value={editForm.password}
|
||||
onChange={(e) => setEditForm((p) => ({ ...p, password: e.target.value }))}
|
||||
placeholder="Set new password (optional)"
|
||||
type="password"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditMode(false)
|
||||
setEditId("")
|
||||
setEditForm(initialUserForm)
|
||||
}}
|
||||
className="rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-100 p-4 dark:border-gray-800">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, username, full name"
|
||||
className="min-w-[220px] flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">All status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="banned">Banned</option>
|
||||
</select>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">All roles</option>
|
||||
<option value="student">student</option>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
{roleSet
|
||||
.filter((r) => r !== "student" && r !== "instructor" && r !== "admin")
|
||||
.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => fetchUsers(1, search, statusFilter, roleFilter)}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="border-b border-gray-100 px-4 py-2 text-sm text-gray-700 dark:border-gray-800 dark:text-gray-200">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Email</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Username</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Role</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Status</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Progress</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Loading users...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-4 text-sm text-gray-500">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user, idx) => {
|
||||
const userId = getUserId(user)
|
||||
const status = String(user.status || "active")
|
||||
const progress = Number(user.progress_percent || user.progress || 0)
|
||||
|
||||
return (
|
||||
<tr key={userId || String(idx)}>
|
||||
<td className="px-3 py-2 text-xs text-gray-800 dark:text-gray-100">{String(user.email || "-")}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(user.username || user.name || "-")}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{String(user.role || "student")}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{status}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-700 dark:text-gray-300">{Number.isFinite(progress) ? `${progress}%` : "0%"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className="rounded bg-gray-100 px-2 py-1 text-gray-800 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startEdit(user)}
|
||||
className="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
disabled={actionLoadingId === `${userId}:suspend`}
|
||||
onClick={() => quickAction(userId, "suspend")}
|
||||
className="rounded bg-amber-600 px-2 py-1 text-white hover:bg-amber-700 disabled:opacity-60"
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
<button
|
||||
disabled={actionLoadingId === `${userId}:ban`}
|
||||
onClick={() => quickAction(userId, "ban")}
|
||||
className="rounded bg-rose-600 px-2 py-1 text-white hover:bg-rose-700 disabled:opacity-60"
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
<button
|
||||
disabled={actionLoadingId === `${userId}:activate`}
|
||||
onClick={() => quickAction(userId, "activate")}
|
||||
className="rounded bg-emerald-600 px-2 py-1 text-white hover:bg-emerald-700 disabled:opacity-60"
|
||||
>
|
||||
Activate
|
||||
</button>
|
||||
<button
|
||||
disabled={actionLoadingId === `${userId}:reset-password`}
|
||||
onClick={() => quickAction(userId, "reset-password")}
|
||||
className="rounded bg-purple-600 px-2 py-1 text-white hover:bg-purple-700 disabled:opacity-60"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
<button
|
||||
onClick={() => quickAction(userId, "activate", "student")}
|
||||
className="rounded bg-slate-600 px-2 py-1 text-white hover:bg-slate-700"
|
||||
>
|
||||
Set Student
|
||||
</button>
|
||||
<button
|
||||
onClick={() => quickAction(userId, "activate", "instructor")}
|
||||
className="rounded bg-slate-600 px-2 py-1 text-white hover:bg-slate-700"
|
||||
>
|
||||
Set Instructor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => quickAction(userId, "activate", "admin")}
|
||||
className="rounded bg-slate-900 px-2 py-1 text-white hover:bg-black"
|
||||
>
|
||||
Set Admin
|
||||
</button>
|
||||
<button
|
||||
disabled={actionLoadingId === `${userId}:delete`}
|
||||
onClick={() => quickAction(userId, "delete")}
|
||||
className="rounded bg-red-800 px-2 py-1 text-white hover:bg-red-900 disabled:opacity-60"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:text-gray-300">
|
||||
<span>
|
||||
Page {pagination.page} of {pagination.pages} | Total {pagination.total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchUsers(Math.max(1, pagination.page - 1), search, statusFilter, roleFilter)}
|
||||
disabled={pagination.page <= 1}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchUsers(Math.min(pagination.pages, pagination.page + 1), search, statusFilter, roleFilter)}
|
||||
disabled={pagination.page >= pagination.pages}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 disabled:opacity-50 dark:bg-gray-800"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedUser ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-4xl rounded-xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Full User Document</h3>
|
||||
<button
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="rounded bg-gray-100 px-3 py-1.5 text-sm hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-[70vh] overflow-auto rounded bg-gray-50 p-3 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-100">
|
||||
{JSON.stringify(selectedUser, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,15 +10,19 @@ import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Wallet, Mail, Lock, Loader2, CheckCircle2 } from "lucide-react"
|
||||
import { toast } from "react-hot-toast"
|
||||
import Link from "next/link"
|
||||
import { MetaMaskEmailModal } from "@/components/metamask-email-modal"
|
||||
|
||||
export default function LoginPage() {
|
||||
const {
|
||||
user,
|
||||
firebaseUser,
|
||||
walletConnected,
|
||||
walletAddress,
|
||||
isLoadingAuth,
|
||||
authMethod,
|
||||
token,
|
||||
showMetaMaskEmailModal,
|
||||
setShowMetaMaskEmailModal,
|
||||
connectWallet,
|
||||
loginWithEmail
|
||||
} = useAuth()
|
||||
@@ -36,7 +40,6 @@ export default function LoginPage() {
|
||||
isLoadingAuth,
|
||||
hasRedirected: hasRedirected.current,
|
||||
user: !!user,
|
||||
firebaseUser: !!firebaseUser,
|
||||
walletConnected,
|
||||
walletAddress,
|
||||
authMethod
|
||||
@@ -50,12 +53,12 @@ export default function LoginPage() {
|
||||
|
||||
// Check for successful authentication
|
||||
const isMetaMaskAuth = walletConnected && walletAddress && user && authMethod === "metamask"
|
||||
const isFirebaseAuth = firebaseUser && authMethod === "firebase"
|
||||
const isAuthenticated = isMetaMaskAuth || isFirebaseAuth
|
||||
const isEmailAuth = user && authMethod === "email"
|
||||
const isAuthenticated = isMetaMaskAuth || isEmailAuth
|
||||
|
||||
console.log("🔍 Authentication check:", {
|
||||
isMetaMaskAuth,
|
||||
isFirebaseAuth,
|
||||
isEmailAuth,
|
||||
isAuthenticated
|
||||
})
|
||||
|
||||
@@ -70,7 +73,6 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [
|
||||
user,
|
||||
firebaseUser,
|
||||
walletConnected,
|
||||
walletAddress,
|
||||
authMethod,
|
||||
@@ -122,7 +124,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
// ✅ Show success state when authenticated but not yet redirected
|
||||
const isAuthenticated = (walletConnected && walletAddress && user) || firebaseUser
|
||||
const isAuthenticated = (walletConnected && walletAddress && user) || (user && authMethod === "email")
|
||||
|
||||
if (isAuthenticated && !hasRedirected.current) {
|
||||
return (
|
||||
@@ -138,7 +140,7 @@ export default function LoginPage() {
|
||||
<p className="text-gray-700">
|
||||
{authMethod === "metamask"
|
||||
? `🦊 MetaMask connected: ${walletAddress?.slice(0, 6)}...${walletAddress?.slice(-4)}`
|
||||
: `📧 Email: ${firebaseUser?.email}`
|
||||
: `📧 Email: ${user?.email || user?.id}`
|
||||
}
|
||||
</p>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
@@ -264,8 +266,34 @@ export default function LoginPage() {
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center pt-4 border-t">
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-purple-600 hover:text-purple-700 font-semibold">
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MetaMask Email Modal */}
|
||||
{token && walletAddress && (
|
||||
<MetaMaskEmailModal
|
||||
isOpen={showMetaMaskEmailModal}
|
||||
walletAddress={walletAddress}
|
||||
token={token}
|
||||
onSuccess={(user) => {
|
||||
setShowMetaMaskEmailModal(false)
|
||||
toast.success("Profile setup complete!")
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMetaMaskEmailModal(false)
|
||||
// User can always add email later from dashboard
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/context/auth-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Wallet, Mail, Lock, Loader2, CheckCircle2 } from "lucide-react"
|
||||
import { toast } from "react-hot-toast"
|
||||
import Link from "next/link"
|
||||
import { MetaMaskEmailModal } from "@/components/metamask-email-modal"
|
||||
|
||||
export default function SignupPage() {
|
||||
const {
|
||||
user,
|
||||
walletConnected,
|
||||
walletAddress,
|
||||
isLoadingAuth,
|
||||
authMethod,
|
||||
token,
|
||||
showMetaMaskEmailModal,
|
||||
setShowMetaMaskEmailModal,
|
||||
connectWallet,
|
||||
signupWithEmail
|
||||
} = useAuth()
|
||||
|
||||
const router = useRouter()
|
||||
const hasRedirected = useRef(false)
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// If already authenticated, redirect to dashboard
|
||||
if ((walletConnected && walletAddress && user) || (user && authMethod === "email")) {
|
||||
if (!hasRedirected.current) {
|
||||
hasRedirected.current = true
|
||||
router.replace("/dashboard")
|
||||
}
|
||||
}
|
||||
}, [user, walletConnected, walletAddress, authMethod, router])
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email.trim() || !password.trim() || !confirmPassword.trim()) {
|
||||
toast.error("Please fill in all fields")
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error("Passwords do not match")
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const success = await signupWithEmail(email, password, username || email.split("@")[0])
|
||||
|
||||
if (success) {
|
||||
// Auth context handles everything including token storage
|
||||
// Redirect will be handled by the useEffect
|
||||
setTimeout(() => {
|
||||
router.replace("/dashboard")
|
||||
}, 500)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Signup error:", error)
|
||||
toast.error(error.message || "Signup failed")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMetaMaskSignup = async () => {
|
||||
try {
|
||||
await connectWallet()
|
||||
toast.success("Connected with MetaMask!")
|
||||
setTimeout(() => {
|
||||
if (walletConnected && walletAddress && user) {
|
||||
router.replace("/dashboard")
|
||||
}
|
||||
}, 500)
|
||||
} catch (error: any) {
|
||||
console.error("MetaMask connection failed:", error)
|
||||
toast.error("MetaMask connection failed")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-gray-900 dark:via-blue-900/20 dark:to-purple-900/20 p-4">
|
||||
<Card className="w-full max-w-md shadow-2xl">
|
||||
<CardHeader className="text-center space-y-4">
|
||||
<CardTitle className="text-2xl font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||
Join OpenLearnX
|
||||
</CardTitle>
|
||||
<p className="text-sm text-gray-600">Create your account to start learning</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* MetaMask Signup */}
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleMetaMaskSignup}
|
||||
disabled={isLoadingAuth || isSubmitting}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 text-white py-3"
|
||||
>
|
||||
{isLoadingAuth ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Connecting MetaMask...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wallet className="w-5 h-5 mr-2" />
|
||||
Sign Up with MetaMask
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300 text-center">
|
||||
Get blockchain features and Web3 verification
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Email Signup Form */}
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="username">Username (optional)</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
disabled={isSubmitting || isLoadingAuth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
disabled={isSubmitting || isLoadingAuth}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 6 characters"
|
||||
disabled={isSubmitting || isLoadingAuth}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
disabled={isSubmitting || isLoadingAuth}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoadingAuth || !email.trim() || !password.trim()}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white py-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Sign Up with Email
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center pt-4 border-t">
|
||||
<p className="text-sm text-gray-600">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/login" className="text-purple-600 hover:text-purple-700 font-semibold">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MetaMask Email Modal */}
|
||||
{token && walletAddress && (
|
||||
<MetaMaskEmailModal
|
||||
isOpen={showMetaMaskEmailModal}
|
||||
walletAddress={walletAddress}
|
||||
token={token}
|
||||
onSuccess={(user) => {
|
||||
setShowMetaMaskEmailModal(false)
|
||||
toast.success("Profile setup complete!")
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowMetaMaskEmailModal(false)
|
||||
// User can always add email later from dashboard
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { Play, Clock, CheckCircle, XCircle, ArrowLeft, Trophy } from 'lucide-react'
|
||||
import { Play, Clock, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'
|
||||
|
||||
interface TestCase {
|
||||
input: string
|
||||
@@ -33,8 +33,10 @@ export default function ProblemPage() {
|
||||
const [testResults, setTestResults] = useState<any[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showHints, setShowHints] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'description' | 'examples' | 'constraints'>('description')
|
||||
const [activeTab, setActiveTab] = useState<'description' | 'editorial' | 'solutions' | 'submissions'>('description')
|
||||
const [detailTab, setDetailTab] = useState<'examples' | 'constraints' | 'hints'>('examples')
|
||||
const [bottomTab, setBottomTab] = useState<'testcase' | 'result'>('testcase')
|
||||
const [customInput, setCustomInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadProblem(problemId)
|
||||
@@ -121,6 +123,7 @@ export default function ProblemPage() {
|
||||
if (selectedProblem) {
|
||||
setProblem(selectedProblem)
|
||||
setCode(selectedProblem.starter_code)
|
||||
setCustomInput(selectedProblem.examples[0]?.input || '')
|
||||
} else {
|
||||
// Problem not found
|
||||
router.push('/coding')
|
||||
@@ -205,72 +208,72 @@ export default function ProblemPage() {
|
||||
|
||||
if (!problem) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-400">Loading problem...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-2 text-muted-foreground">Loading problem...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const passedCount = testResults.filter((result) => result.passed).length
|
||||
const allPassed = testResults.length > 0 && passedCount === testResults.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-800 border-b border-gray-700 p-4">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
onClick={() => router.push('/coding')}
|
||||
className="rounded-md border border-border p-2 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{problem.title}</h1>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getDifficultyColor(problem.difficulty)}`}>
|
||||
<h1 className="text-lg font-semibold">{problem.id}. {problem.title}</h1>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={`rounded px-2 py-0.5 font-medium ${getDifficultyColor(problem.difficulty)}`}>
|
||||
{problem.difficulty}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm">{problem.category}</span>
|
||||
<span>{problem.category}</span>
|
||||
{allPassed && <span className="text-emerald-600 dark:text-emerald-400">Solved</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHints(!showHints)}
|
||||
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 rounded-lg text-sm transition-colors"
|
||||
onClick={runCode}
|
||||
disabled={isRunning || !code.trim()}
|
||||
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm text-secondary-foreground hover:bg-accent disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{showHints ? 'Hide Hints' : 'Show Hints'}
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/coding/exam')}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm transition-colors flex items-center space-x-2"
|
||||
onClick={submitSolution}
|
||||
disabled={isSubmitting || !code.trim()}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trophy className="h-4 w-4" />
|
||||
<span>Join Exam</span>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Problem Description */}
|
||||
<div className="space-y-6">
|
||||
{/* Navigation Tabs */}
|
||||
<div className="bg-gray-800 rounded-lg">
|
||||
<div className="flex border-b border-gray-700">
|
||||
{(['description', 'examples', 'constraints'] as const).map((tab) => (
|
||||
<main className="h-[calc(100vh-73px)] p-3">
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex border-b border-border text-sm">
|
||||
{(['description', 'editorial', 'solutions', 'submissions'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-6 py-3 font-medium capitalize transition-colors ${
|
||||
className={`px-4 py-3 capitalize ${
|
||||
activeTab === tab
|
||||
? 'bg-gray-700 text-white border-b-2 border-blue-500'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
@@ -278,164 +281,197 @@ export default function ProblemPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-xs">
|
||||
<button
|
||||
onClick={() => setDetailTab('examples')}
|
||||
className={`rounded px-2 py-1 ${detailTab === 'examples' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Examples
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDetailTab('constraints')}
|
||||
className={`rounded px-2 py-1 ${detailTab === 'constraints' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Constraints
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDetailTab('hints')}
|
||||
className={`rounded px-2 py-1 ${detailTab === 'hints' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Hints
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'description' && (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p className="text-gray-300 leading-relaxed">{problem.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p className="leading-7">{problem.description}</p>
|
||||
|
||||
{activeTab === 'examples' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Examples:</h3>
|
||||
{problem.examples.map((example, index) => (
|
||||
<div key={index} className="bg-gray-900 p-4 rounded-lg">
|
||||
<div className="mb-2">
|
||||
<span className="text-blue-400">Input:</span>
|
||||
<code className="ml-2 text-green-400">"{example.input}"</code>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="text-blue-400">Output:</span>
|
||||
<code className="ml-2 text-green-400">"{example.expected}"</code>
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm">{example.description}</div>
|
||||
{detailTab === 'examples' && (
|
||||
<div className="space-y-3">
|
||||
{problem.examples.map((example, index) => (
|
||||
<div key={index} className="rounded-lg border border-border bg-secondary/40 p-3">
|
||||
<p className="font-medium text-foreground">Example {index + 1}</p>
|
||||
<p className="mt-2"><span className="text-muted-foreground">Input:</span> <code className="text-primary">{example.input}</code></p>
|
||||
<p><span className="text-muted-foreground">Output:</span> <code className="text-primary">{example.expected}</code></p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{example.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{detailTab === 'constraints' && (
|
||||
<ul className="space-y-2 text-muted-foreground">
|
||||
{problem.constraints.map((constraint, index) => (
|
||||
<li key={index} className="rounded border border-border bg-secondary/40 px-3 py-2">
|
||||
{constraint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{detailTab === 'hints' && (
|
||||
<ul className="space-y-2">
|
||||
{problem.hints.map((hint, index) => (
|
||||
<li key={index} className="rounded border border-amber-300 bg-amber-100/70 px-3 py-2 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
{index + 1}. {hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'constraints' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Constraints:</h3>
|
||||
<ul className="space-y-2">
|
||||
{problem.constraints.map((constraint, index) => (
|
||||
<li key={index} className="flex items-start space-x-2">
|
||||
<span className="text-blue-400 mt-1">•</span>
|
||||
<span className="text-gray-300">{constraint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{activeTab === 'editorial' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Editorial</p>
|
||||
<p className="mt-2">Approach: Use the Python string method that transforms text to uppercase and return it directly from <code className="text-primary">{problem.function_name}</code>.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'solutions' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Community Solutions</p>
|
||||
<p className="mt-2">Your submitted solutions will appear here after running Submit.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'submissions' && (
|
||||
<div className="rounded-lg border border-border bg-secondary/40 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Submissions</p>
|
||||
<p className="mt-2">No submissions yet. Run and submit your code to populate this section.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hints Section */}
|
||||
{showHints && (
|
||||
<div className="bg-yellow-900 border border-yellow-600 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 text-yellow-300">💡 Hints:</h3>
|
||||
<ul className="space-y-2">
|
||||
{problem.hints.map((hint, index) => (
|
||||
<li key={index} className="flex items-start space-x-2">
|
||||
<span className="text-yellow-400 mt-1">{index + 1}.</span>
|
||||
<span className="text-yellow-100">{hint}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2 text-sm">
|
||||
<span className="text-foreground">Code</span>
|
||||
<span className="text-xs text-muted-foreground">Python</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Editor & Results */}
|
||||
<div className="space-y-6">
|
||||
{/* Code Editor */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold">Code Editor</h3>
|
||||
<span className="text-sm text-gray-400">Python</span>
|
||||
<div className="min-h-0 flex-1 border-b border-border">
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="h-full w-full resize-none bg-background p-4 font-mono text-sm text-foreground outline-none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full h-80 bg-gray-900 text-green-400 font-mono p-4 rounded border border-gray-600 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
Function: <code className="text-blue-400">{problem.function_name}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
|
||||
<div className="h-[38%] min-h-[220px]">
|
||||
<div className="flex border-b border-border text-sm">
|
||||
<button
|
||||
onClick={runCode}
|
||||
disabled={isRunning || !code.trim()}
|
||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
|
||||
onClick={() => setBottomTab('testcase')}
|
||||
className={`px-4 py-2 ${bottomTab === 'testcase' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span>{isRunning ? 'Running...' : 'Run Code'}</span>
|
||||
Testcase
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={submitSolution}
|
||||
disabled={isSubmitting || !code.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
|
||||
onClick={() => setBottomTab('result')}
|
||||
className={`px-4 py-2 ${bottomTab === 'result' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>{isSubmitting ? 'Submitting...' : 'Submit'}</span>
|
||||
Test Result
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output & Test Results */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Output & Test Results</h3>
|
||||
|
||||
{/* Console Output */}
|
||||
{output && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-2">Console Output:</h4>
|
||||
<div className="bg-black p-4 rounded font-mono text-sm">
|
||||
<pre className="text-green-400 whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-2">Test Results:</h4>
|
||||
<div className="space-y-2">
|
||||
{testResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded flex items-center justify-between ${
|
||||
result.passed ? 'bg-green-900 border border-green-600' : 'bg-red-900 border border-red-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.passed ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
)}
|
||||
<span className="text-sm">Test {index + 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm">
|
||||
{result.passed ? (
|
||||
<span className="text-green-400">Passed</span>
|
||||
) : (
|
||||
<span className="text-red-400">Failed: {result.error}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
{bottomTab === 'testcase' && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-medium text-muted-foreground">Custom Input</label>
|
||||
<textarea
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
className="h-24 w-full rounded border border-border bg-secondary/40 p-3 font-mono text-sm text-foreground outline-none"
|
||||
placeholder="Enter custom testcase input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Function: <code className="text-primary">{problem.function_name}</code></p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={runCode}
|
||||
disabled={isRunning || !code.trim()}
|
||||
className="inline-flex items-center gap-2 rounded bg-secondary px-3 py-2 text-sm text-secondary-foreground hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
<button
|
||||
onClick={submitSolution}
|
||||
disabled={isSubmitting || !code.trim()}
|
||||
className="inline-flex items-center gap-2 rounded bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!output && testResults.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<Clock className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Run your code to see output and test results</p>
|
||||
{bottomTab === 'result' && (
|
||||
<div className="space-y-3">
|
||||
{output && (
|
||||
<div className="rounded border border-border bg-secondary/40 p-3">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Console</p>
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Passed {passedCount}/{testResults.length} tests</p>
|
||||
{testResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-between rounded border px-3 py-2 text-sm ${
|
||||
result.passed
|
||||
? 'border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
: 'border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-950/40 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{result.passed ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||||
Test {index + 1}
|
||||
</span>
|
||||
<span>{result.passed ? 'Passed' : `Failed${result.error ? `: ${result.error}` : ''}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!output && testResults.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Clock className="mx-auto mb-2 h-8 w-8 opacity-60" />
|
||||
Run your code to see results.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { Trophy, Clock, Users, Send, RefreshCw, Play, Code, Wallet, Shield, TestTube } from 'lucide-react'
|
||||
import { Trophy, Clock, Users, Send, RefreshCw, Play, Code, Shield, TestTube } from 'lucide-react'
|
||||
|
||||
interface Participant {
|
||||
name: string
|
||||
@@ -54,6 +54,8 @@ export default function EnhancedExamInterface() {
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||
const [examStats, setExamStats] = useState<any>({})
|
||||
const [timerInitialized, setTimerInitialized] = useState(false)
|
||||
const [leftTab, setLeftTab] = useState<'description' | 'examples' | 'constraints'>('description')
|
||||
const [rightTab, setRightTab] = useState<'result' | 'leaderboard'>('result')
|
||||
|
||||
// ✅ CRITICAL FIX: Use refs to prevent infinite loops
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -62,11 +64,11 @@ export default function EnhancedExamInterface() {
|
||||
const isInitializedRef = useRef(false)
|
||||
|
||||
const languageIcons: {[key: string]: string} = {
|
||||
python: '🐍',
|
||||
java: '☕',
|
||||
javascript: '🟨',
|
||||
c: '⚡',
|
||||
bash: '💻'
|
||||
python: 'Py',
|
||||
java: 'Java',
|
||||
javascript: 'JS',
|
||||
c: 'C',
|
||||
bash: 'Sh'
|
||||
}
|
||||
|
||||
// ✅ FIXED: Memoized functions to prevent recreation
|
||||
@@ -199,7 +201,7 @@ export default function EnhancedExamInterface() {
|
||||
setTimeRemaining(prev => {
|
||||
const newTime = Math.max(0, prev - 1)
|
||||
if (newTime === 0) {
|
||||
alert('⏰ Time is up! Exam has ended.')
|
||||
alert('Time is up. Exam has ended.')
|
||||
}
|
||||
return newTime
|
||||
})
|
||||
@@ -255,12 +257,12 @@ export default function EnhancedExamInterface() {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setOutput(`✅ Output:\n${result.output}`)
|
||||
setOutput(`Output:\n${result.output}`)
|
||||
if (result.execution_time) {
|
||||
setOutput(prev => prev + `\n⏱️ Execution time: ${result.execution_time}s`)
|
||||
setOutput(prev => prev + `\nExecution time: ${result.execution_time}s`)
|
||||
}
|
||||
} else {
|
||||
setOutput(`❌ Error:\n${result.error}`)
|
||||
setOutput(`Error:\n${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
setOutput(`Execution failed: ${(error as Error).message}`)
|
||||
@@ -313,15 +315,15 @@ export default function EnhancedExamInterface() {
|
||||
setHasSubmitted(true)
|
||||
setTestResults(data.result?.test_results || [])
|
||||
|
||||
let alertMessage = `🎉 Solution submitted successfully!\n\n`
|
||||
alertMessage += `📊 Overall Score: ${data.result?.score || 0}%\n`
|
||||
alertMessage += `✅ Tests Passed: ${data.result?.passed_tests || 0}/${data.result?.total_tests || 1}\n`
|
||||
let alertMessage = `Solution submitted successfully.\n\n`
|
||||
alertMessage += `Overall Score: ${data.result?.score || 0}%\n`
|
||||
alertMessage += `Tests Passed: ${data.result?.passed_tests || 0}/${data.result?.total_tests || 1}\n`
|
||||
|
||||
if (data.result?.execution_time) {
|
||||
alertMessage += `⏱️ Execution Time: ${data.result.execution_time}s\n`
|
||||
alertMessage += `Execution Time: ${data.result.execution_time}s\n`
|
||||
}
|
||||
|
||||
alertMessage += `\n🏆 Check the leaderboard for your ranking!`
|
||||
alertMessage += `\nCheck the leaderboard for your ranking.`
|
||||
alert(alertMessage)
|
||||
|
||||
// ✅ FIXED: Controlled refresh sequence - clear previous timeouts
|
||||
@@ -342,12 +344,12 @@ export default function EnhancedExamInterface() {
|
||||
refreshTimeoutRefs.current.push(refreshTimeout)
|
||||
|
||||
} else {
|
||||
alert(`❌ Submission failed: ${data.error}`)
|
||||
alert(`Submission failed: ${data.error}`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Submit network error:', error)
|
||||
alert('❌ Network error: Could not submit solution. Please try again.')
|
||||
console.error('Submit network error:', error)
|
||||
alert('Network error: Could not submit solution. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -364,9 +366,9 @@ export default function EnhancedExamInterface() {
|
||||
if (!results || results.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-6 bg-gray-900 p-4 rounded border border-gray-600">
|
||||
<h4 className="text-lg font-semibold text-white mb-4 flex items-center space-x-2">
|
||||
<TestTube className="h-5 w-5 text-blue-400" />
|
||||
<div className="mt-6 rounded border border-border bg-secondary/40 p-4">
|
||||
<h4 className="mb-4 flex items-center space-x-2 text-lg font-semibold text-foreground">
|
||||
<TestTube className="h-5 w-5 text-primary" />
|
||||
<span>Test Results</span>
|
||||
</h4>
|
||||
|
||||
@@ -383,9 +385,9 @@ export default function EnhancedExamInterface() {
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">
|
||||
Test {index + 1}: {result.passed ? '✅ PASSED' : '❌ FAILED'}
|
||||
Test {index + 1}: {result.passed ? 'PASSED' : 'FAILED'}
|
||||
</span>
|
||||
<span className="text-sm bg-black bg-opacity-30 px-2 py-1 rounded font-bold">
|
||||
<span className="rounded bg-secondary px-2 py-1 text-sm font-bold text-secondary-foreground">
|
||||
+{result.points_earned || 0} points
|
||||
</span>
|
||||
</div>
|
||||
@@ -399,7 +401,7 @@ export default function EnhancedExamInterface() {
|
||||
{result.input && (
|
||||
<div>
|
||||
<span className="font-medium">Input:</span>
|
||||
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
|
||||
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
|
||||
"{result.input}"
|
||||
</code>
|
||||
</div>
|
||||
@@ -408,7 +410,7 @@ export default function EnhancedExamInterface() {
|
||||
{result.expected_output && (
|
||||
<div>
|
||||
<span className="font-medium">Expected:</span>
|
||||
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
|
||||
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
|
||||
"{result.expected_output}"
|
||||
</code>
|
||||
</div>
|
||||
@@ -417,7 +419,7 @@ export default function EnhancedExamInterface() {
|
||||
{result.actual_output && (
|
||||
<div>
|
||||
<span className="font-medium">Your Output:</span>
|
||||
<code className="ml-2 bg-black bg-opacity-30 px-2 py-1 rounded">
|
||||
<code className="ml-2 rounded bg-secondary px-2 py-1 text-secondary-foreground">
|
||||
"{result.actual_output}"
|
||||
</code>
|
||||
</div>
|
||||
@@ -425,7 +427,7 @@ export default function EnhancedExamInterface() {
|
||||
</div>
|
||||
|
||||
{!result.passed && result.error && (
|
||||
<div className="mt-2 p-2 bg-red-800 bg-opacity-50 rounded text-sm">
|
||||
<div className="mt-2 rounded bg-red-100 p-2 text-sm text-red-800 dark:bg-red-900/40 dark:text-red-200">
|
||||
<span className="font-medium">Error:</span> {result.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -455,270 +457,199 @@ export default function EnhancedExamInterface() {
|
||||
|
||||
if (!examSession || !problem) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-400">Loading exam interface...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="mt-2 text-muted-foreground">Loading exam interface...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
{/* Header with Timer */}
|
||||
<div className="bg-gray-800 border-b border-gray-700 p-4">
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{problem.title}</h1>
|
||||
<p className="text-gray-400">Code: {examCode} | Participant: {examSession.student_name}</p>
|
||||
<h1 className="text-lg font-semibold">{problem.title}</h1>
|
||||
<p className="text-xs text-muted-foreground">Code: {examCode} | Participant: {examSession.student_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Timer */}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{timeRemaining > 0 && (
|
||||
<div className={`flex items-center space-x-2 px-3 py-1 rounded-lg ${
|
||||
timeRemaining <= 300 ? 'bg-red-900' : timeRemaining <= 600 ? 'bg-yellow-900' : 'bg-green-900'
|
||||
<div className={`rounded-md px-3 py-1 text-sm font-mono ${
|
||||
timeRemaining <= 300 ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' : timeRemaining <= 600 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
}`}>
|
||||
<Clock className={`h-5 w-5 ${
|
||||
timeRemaining <= 300 ? 'text-red-400' : timeRemaining <= 600 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`} />
|
||||
<span className={`font-mono text-lg ${
|
||||
timeRemaining <= 300 ? 'text-red-400' : timeRemaining <= 600 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}>
|
||||
{formatTime(timeRemaining)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1"><Clock className="h-4 w-4" /> {formatTime(timeRemaining)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participant Count */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="h-5 w-5 text-blue-400" />
|
||||
<span>{examStats.total_participants || 0} participants</span>
|
||||
<div className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" /> {examStats.total_participants || 0}
|
||||
</div>
|
||||
|
||||
{/* Submission Status Indicator */}
|
||||
{hasSubmitted && (
|
||||
<div className="flex items-center space-x-2 bg-green-900 px-3 py-1 rounded-lg">
|
||||
<Shield className="h-4 w-4 text-green-400" />
|
||||
<span className="text-green-200 text-sm">✅ Submitted</span>
|
||||
<div className="inline-flex items-center gap-1 rounded-md bg-emerald-100 px-2 py-1 text-sm text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Shield className="h-4 w-4" /> Submitted
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Problem & Code Editor */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Problem Description */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold">{problem.title}</h2>
|
||||
{hasSubmitted && (
|
||||
<div className="flex items-center space-x-1 text-green-400 text-sm">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Solution Submitted</span>
|
||||
<main className="h-[calc(100vh-73px)] p-3">
|
||||
<div className="grid h-full grid-cols-1 gap-3 xl:grid-cols-5">
|
||||
<section className="xl:col-span-2 flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex border-b border-border text-sm">
|
||||
<button onClick={() => setLeftTab('description')} className={`px-4 py-2 ${leftTab === 'description' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Description</button>
|
||||
<button onClick={() => setLeftTab('examples')} className={`px-4 py-2 ${leftTab === 'examples' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Examples</button>
|
||||
<button onClick={() => setLeftTab('constraints')} className={`px-4 py-2 ${leftTab === 'constraints' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Constraints</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 text-sm text-muted-foreground">
|
||||
{leftTab === 'description' && <p className="leading-7">{problem.description}</p>}
|
||||
{leftTab === 'examples' && (
|
||||
<div className="space-y-3">
|
||||
{problem.examples.map((example, index) => (
|
||||
<div key={index} className="rounded-lg border border-border bg-secondary/40 p-3">
|
||||
<p className="font-medium text-foreground">Example {index + 1}</p>
|
||||
<p className="mt-1"><span className="text-muted-foreground">Input:</span> <code className="text-primary">{example.input}</code></p>
|
||||
<p><span className="text-muted-foreground">Output:</span> <code className="text-primary">{example.expected_output}</code></p>
|
||||
{example.description ? <p className="mt-1 text-xs text-muted-foreground">{example.description}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{leftTab === 'constraints' && (
|
||||
<ul className="space-y-2">
|
||||
{problem.constraints.map((constraint, index) => (
|
||||
<li key={index} className="rounded border border-border bg-secondary/40 px-3 py-2">{constraint}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert">
|
||||
<p className="mb-4 text-gray-300">{problem.description}</p>
|
||||
|
||||
<h4 className="text-lg font-semibold mb-2">Examples:</h4>
|
||||
{problem.examples.map((example, index) => (
|
||||
<div key={index} className="bg-gray-900 p-4 rounded mb-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-blue-400">Input:</span>
|
||||
<code className="ml-2 text-green-400">"{example.input}"</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-400">Output:</span>
|
||||
<code className="ml-2 text-green-400">"{example.expected_output}"</code>
|
||||
</div>
|
||||
</div>
|
||||
{example.description && (
|
||||
<div className="mt-2 text-gray-400 text-sm">{example.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<h4 className="text-lg font-semibold mb-2">Constraints:</h4>
|
||||
<ul className="list-disc list-inside mb-4 text-gray-300">
|
||||
{problem.constraints.map((constraint, index) => (
|
||||
<li key={index}>{constraint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold">Your Solution</h3>
|
||||
|
||||
{/* Language Selector */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Code className="h-4 w-4 text-gray-400" />
|
||||
<section className="xl:col-span-3 flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Code className="h-4 w-4" />
|
||||
<select
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
disabled={hasSubmitted}
|
||||
className="bg-gray-700 text-white px-3 py-1 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500"
|
||||
className="rounded border border-border bg-secondary px-2 py-1 text-sm text-secondary-foreground"
|
||||
>
|
||||
{problem.languages.map(lang => (
|
||||
<option key={lang} value={lang}>
|
||||
{languageIcons[lang]} {lang.charAt(0).toUpperCase() + lang.slice(1)}
|
||||
</option>
|
||||
<option key={lang} value={lang}>{languageIcons[lang]} {lang.charAt(0).toUpperCase() + lang.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">Function: {problem.function_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="w-full h-64 bg-gray-900 text-green-400 font-mono p-4 rounded border border-gray-600 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={hasSubmitted}
|
||||
spellCheck={false}
|
||||
placeholder={hasSubmitted ? 'Solution submitted!' : `Write your ${selectedLanguage} solution here...`}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-400">
|
||||
Function: <code className="text-blue-400">{problem.function_name}</code>
|
||||
{hasSubmitted && (
|
||||
<span className="ml-4 text-green-400">
|
||||
✅ Solution submitted successfully!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={runCode}
|
||||
disabled={isRunning || hasSubmitted || !code.trim()}
|
||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
|
||||
className="inline-flex items-center gap-1 rounded bg-secondary px-3 py-1.5 text-sm text-secondary-foreground hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span>{isRunning ? 'Running...' : 'Test Code'}</span>
|
||||
<Play className="h-4 w-4" /> {isRunning ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={submitSolution}
|
||||
onClick={submitSolution}
|
||||
disabled={isSubmitting || hasSubmitted || !code.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded flex items-center space-x-2 transition-colors"
|
||||
className="inline-flex items-center gap-1 rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-60"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<span>{isSubmitting ? 'Submitting...' : hasSubmitted ? 'Submitted ✅' : 'Submit Solution'}</span>
|
||||
<Send className="h-4 w-4" /> {isSubmitting ? 'Submitting...' : hasSubmitted ? 'Submitted' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Display */}
|
||||
{output && (
|
||||
<div className="mt-6 bg-gray-900 p-4 rounded">
|
||||
<h4 className="text-sm font-medium text-gray-400 mb-2">Output:</h4>
|
||||
<pre className="text-green-400 text-sm whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Results Display */}
|
||||
{testResults.length > 0 && (
|
||||
<TestResultsDisplay results={testResults} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced Leaderboard */}
|
||||
<div className="bg-gray-800 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Trophy className="h-6 w-6 text-yellow-400" />
|
||||
<h3 className="text-xl font-bold">Live Leaderboard</h3>
|
||||
<div className="min-h-0 flex-1 border-b border-border">
|
||||
<textarea
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
className="h-full w-full resize-none bg-background p-4 font-mono text-sm text-foreground outline-none"
|
||||
disabled={hasSubmitted}
|
||||
spellCheck={false}
|
||||
placeholder={hasSubmitted ? 'Solution submitted.' : `Write your ${selectedLanguage} solution here...`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={manualRefresh}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-gray-900 p-3 rounded">
|
||||
<div className="text-2xl font-bold text-blue-400">{examStats.completed_submissions || 0}</div>
|
||||
<div className="text-xs text-gray-400">Submitted</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 p-3 rounded">
|
||||
<div className="text-2xl font-bold text-green-400">{Math.round(examStats.average_score || 0)}%</div>
|
||||
<div className="text-xs text-gray-400">Avg Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Display */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-gray-300 mb-3">🏆 Rankings</h4>
|
||||
{leaderboard.length > 0 ? (
|
||||
leaderboard.map((participant) => (
|
||||
<div key={participant.name} className={`p-3 rounded-lg ${getRankColor(participant.rank)}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="font-bold text-lg">#{participant.rank}</span>
|
||||
<div>
|
||||
<div className={`font-medium ${participant.name === examSession.student_name ? 'underline font-bold' : ''}`}>
|
||||
{participant.name}
|
||||
{participant.name === examSession.student_name && ' (You) 🎯'}
|
||||
</div>
|
||||
<div className="text-xs opacity-75 flex items-center space-x-2">
|
||||
{participant.language && (
|
||||
<span>
|
||||
{languageIcons[participant.language]} {participant.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-lg">{participant.score}%</span>
|
||||
<div className="text-xs opacity-75">
|
||||
Submitted ✅
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[40%] min-h-[240px]">
|
||||
<div className="flex items-center justify-between border-b border-border px-2">
|
||||
<div className="flex text-sm">
|
||||
<button onClick={() => setRightTab('result')} className={`px-3 py-2 ${rightTab === 'result' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Test Result</button>
|
||||
<button onClick={() => setRightTab('leaderboard')} className={`px-3 py-2 ${rightTab === 'leaderboard' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground'}`}>Leaderboard</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
No submissions yet
|
||||
{rightTab === 'leaderboard' && (
|
||||
<button onClick={manualRefresh} className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground" title="Refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Waiting Participants */}
|
||||
{waitingParticipants.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-semibold text-gray-300 mb-3">⏳ Still Working</h4>
|
||||
<div className="space-y-1">
|
||||
{waitingParticipants.map((participant) => (
|
||||
<div key={participant.name} className="p-2 bg-gray-700 rounded text-sm flex items-center justify-between">
|
||||
<span>
|
||||
{participant.name}
|
||||
{participant.name === examSession.student_name && ' (You)'}
|
||||
</span>
|
||||
<span className="text-yellow-400 text-xs">Working...</span>
|
||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||
{rightTab === 'result' && (
|
||||
<div className="space-y-3">
|
||||
{output ? (
|
||||
<div className="rounded border border-border bg-secondary/40 p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-foreground">{output}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Run your code to see output.</p>
|
||||
)}
|
||||
{testResults.length > 0 ? <TestResultsDisplay results={testResults} /> : null}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{rightTab === 'leaderboard' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded border border-border bg-secondary/40 p-3">
|
||||
<p className="text-xl font-bold text-primary">{examStats.completed_submissions || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Submitted</p>
|
||||
</div>
|
||||
<div className="rounded border border-border bg-secondary/40 p-3">
|
||||
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">{Math.round(examStats.average_score || 0)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Average Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="inline-flex items-center gap-2 text-sm font-semibold text-foreground"><Trophy className="h-4 w-4 text-yellow-500" /> Rankings</h4>
|
||||
{leaderboard.length > 0 ? leaderboard.map((participant) => (
|
||||
<div key={participant.name} className={`rounded p-3 ${getRankColor(participant.rank)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${participant.name === examSession.student_name ? 'underline' : ''}`}>
|
||||
#{participant.rank} {participant.name}{participant.name === examSession.student_name ? ' (You)' : ''}
|
||||
</p>
|
||||
<p className="text-xs opacity-80">{participant.language || 'language'} • submitted</p>
|
||||
</div>
|
||||
<p className="font-semibold">{participant.score}%</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-sm text-muted-foreground">No submissions yet.</p>}
|
||||
</div>
|
||||
|
||||
{waitingParticipants.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-foreground">Still Working</h4>
|
||||
<div className="space-y-1">
|
||||
{waitingParticipants.map((participant) => (
|
||||
<div key={participant.name} className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm text-secondary-foreground">
|
||||
<span>{participant.name}{participant.name === examSession.student_name ? ' (You)' : ''}</span>
|
||||
<span className="text-xs text-amber-600 dark:text-amber-300">Working...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function ExamLandingPage() {
|
||||
onChange={(e) => setExamCode(e.target.value.toUpperCase())}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Enter exam code (e.g. ABC123)"
|
||||
className="flex-1 p-4 bg-gray-700 border border-gray-600 rounded-lg text-center text-xl font-mono tracking-widest"
|
||||
className="flex-1 p-4 bg-gray-700 border border-gray-600 rounded-lg text-center text-xl font-mono tracking-widest text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -27,10 +27,16 @@ export default function JoinExam() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token")
|
||||
const storedUserRaw = localStorage.getItem("openlearnx_user")
|
||||
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
|
||||
|
||||
// ✅ CORRECT FIELD NAMES - Must match backend expectations
|
||||
const payload = {
|
||||
exam_code: examCode.trim().toUpperCase(), // Backend expects exam_code
|
||||
student_name: studentName.trim() // Backend expects student_name
|
||||
student_name: studentName.trim(), // Backend expects student_name
|
||||
wallet_address: storedUser?.wallet_address,
|
||||
user_id: storedUser?.id
|
||||
}
|
||||
|
||||
console.log('🚀 Sending payload:', payload)
|
||||
@@ -39,7 +45,8 @@ export default function JoinExam() {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
'Accept': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify(payload) // ✅ MUST stringify the payload
|
||||
})
|
||||
|
||||
@@ -289,7 +289,7 @@ Redirecting to exam interface...`)
|
||||
// Role Selection Screen with Enhanced Animations
|
||||
if (userRole === 'selector') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-white rounded-full animate-float"></div>
|
||||
@@ -310,7 +310,7 @@ Redirecting to exam interface...`)
|
||||
<Star className="w-4 h-4 text-white opacity-50 animate-spin-slow" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-2xl p-10 max-w-lg w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
|
||||
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-sm rounded-2xl shadow-2xl p-10 max-w-lg w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
|
||||
{/* Card shine effect */}
|
||||
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000"></div>
|
||||
|
||||
@@ -319,10 +319,10 @@ Redirecting to exam interface...`)
|
||||
<div className="flex justify-center mb-4 animate-bounce">
|
||||
<Code className="h-16 w-16 text-blue-600 animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-3 animate-slide-down">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white mb-3 animate-slide-down">
|
||||
OpenLearnX Coding Exam
|
||||
</h1>
|
||||
<p className="text-gray-600 animate-fade-in animate-delay-300">
|
||||
<p className="text-gray-600 dark:text-gray-300 animate-fade-in animate-delay-300">
|
||||
Choose your role to get started
|
||||
</p>
|
||||
</div>
|
||||
@@ -330,7 +330,7 @@ Redirecting to exam interface...`)
|
||||
<div className="space-y-6">
|
||||
<button
|
||||
onClick={() => setUserRole('host')}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 dark:from-blue-700 dark:to-blue-800 hover:from-blue-700 hover:to-blue-800 dark:hover:from-blue-800 dark:hover:to-blue-900 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
>
|
||||
{/* Button background animation */}
|
||||
@@ -349,7 +349,7 @@ Redirecting to exam interface...`)
|
||||
|
||||
<button
|
||||
onClick={() => setUserRole('participant')}
|
||||
className="w-full bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
|
||||
className="w-full bg-gradient-to-r from-green-600 to-green-700 dark:from-green-700 dark:to-green-800 hover:from-green-700 hover:to-green-800 dark:hover:from-green-800 dark:hover:to-green-900 text-white py-4 px-6 rounded-xl flex items-center justify-center space-x-3 transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 animate-slide-up group relative overflow-hidden"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
{/* Button background animation */}
|
||||
@@ -369,7 +369,7 @@ Redirecting to exam interface...`)
|
||||
|
||||
{/* Animated footer */}
|
||||
<div className="mt-8 text-center animate-fade-in animate-delay-500">
|
||||
<p className="text-sm text-gray-500 hover:text-gray-700 transition-colors duration-300">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-300">
|
||||
Secure • Real-time • Professional
|
||||
</p>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@ Redirecting to exam interface...`)
|
||||
// Host Setup Screen with Enhanced UI
|
||||
if (userRole === 'host' && !examId) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-indigo-900 to-purple-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
{/* Enhanced background animations */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-white rounded-full mix-blend-overlay animate-blob"></div>
|
||||
@@ -398,7 +398,7 @@ Redirecting to exam interface...`)
|
||||
<Zap className="w-6 h-6 text-white opacity-20 animate-bounce" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
|
||||
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
|
||||
{/* Enhanced shine effect */}
|
||||
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-blue-200/30 to-transparent transition-transform duration-1000"></div>
|
||||
|
||||
@@ -412,10 +412,10 @@ Redirecting to exam interface...`)
|
||||
<div className="absolute -top-2 -right-2 w-3 h-3 bg-blue-400 rounded-full animate-ping"></div>
|
||||
<div className="absolute -bottom-2 -left-2 w-2 h-2 bg-blue-300 rounded-full animate-ping animation-delay-500"></div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-4 animate-slide-down">
|
||||
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-4 animate-slide-down">
|
||||
Host Coding Exam
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg animate-fade-in animate-delay-300">
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg animate-fade-in animate-delay-300">
|
||||
Create a secure coding environment for your participants
|
||||
</p>
|
||||
</div>
|
||||
@@ -427,7 +427,7 @@ Redirecting to exam interface...`)
|
||||
placeholder="Enter your name"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
className="w-full p-4 border-2 border-gray-200 rounded-xl text-lg transition-all duration-300 focus:ring-4 focus:ring-blue-200 focus:border-blue-500 hover:border-blue-300 bg-gray-50 hover:bg-white focus:bg-white group"
|
||||
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 rounded-xl text-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-800 focus:border-blue-500 dark:focus:border-blue-400 hover:border-blue-300 dark:hover:border-blue-500 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 group"
|
||||
/>
|
||||
{/* Input decoration */}
|
||||
<div className="absolute right-4 top-1/2 transform -translate-y-1/2 opacity-0 group-focus-within:opacity-100 transition-opacity duration-300">
|
||||
@@ -438,7 +438,7 @@ Redirecting to exam interface...`)
|
||||
<button
|
||||
onClick={createExam}
|
||||
disabled={!participantName}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 disabled:from-gray-400 disabled:to-gray-500 text-white py-4 px-6 rounded-xl text-lg font-semibold transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 disabled:hover:scale-100 animate-slide-up group relative overflow-hidden"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-700 dark:to-indigo-700 hover:from-blue-700 hover:to-indigo-700 dark:hover:from-blue-800 dark:hover:to-indigo-800 disabled:from-gray-400 disabled:to-gray-500 text-white py-4 px-6 rounded-xl text-lg font-semibold transform transition-all duration-300 hover:scale-105 hover:shadow-2xl active:scale-95 disabled:hover:scale-100 animate-slide-up group relative overflow-hidden"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
>
|
||||
{/* Button animation background */}
|
||||
@@ -455,7 +455,7 @@ Redirecting to exam interface...`)
|
||||
</div>
|
||||
|
||||
{/* Enhanced Debug Info */}
|
||||
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-blue-50 rounded-xl text-sm text-gray-600 animate-fade-in border border-gray-200 hover:border-blue-300 transition-colors duration-300" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="mt-8 p-6 bg-gradient-to-r from-gray-50 to-blue-50 dark:from-gray-700 dark:to-gray-800 rounded-xl text-sm text-gray-600 dark:text-gray-300 animate-fade-in border border-gray-200 dark:border-gray-600 hover:border-blue-300 dark:hover:border-blue-500 transition-colors duration-300" style={{ animationDelay: '0.3s' }}>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-semibold">System Status</span>
|
||||
@@ -484,7 +484,7 @@ Redirecting to exam interface...`)
|
||||
// Join Exam Screen with Enhanced Animations
|
||||
if (userRole === 'participant' && !examInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-900 via-emerald-900 to-blue-900 flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-green-50 to-blue-50 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#1f4f63] dark:to-[#274f80] flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
{/* Enhanced background effects */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-1/4 left-1/4 w-40 h-40 bg-white rounded-full animate-float hover:scale-150 transition-transform duration-500"></div>
|
||||
@@ -499,7 +499,7 @@ Redirecting to exam interface...`)
|
||||
<div className="absolute top-1/2 left-1/5 w-2.5 h-2.5 bg-white rounded-full animate-pulse animate-delay-700 opacity-50"></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group">
|
||||
<div className="bg-white/95 dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl shadow-2xl p-12 max-w-xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 relative overflow-hidden group border border-gray-200 dark:border-blue-300/20">
|
||||
{/* Enhanced card effects */}
|
||||
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-green-200/30 to-transparent transition-transform duration-1000"></div>
|
||||
|
||||
@@ -513,10 +513,10 @@ Redirecting to exam interface...`)
|
||||
<div className="absolute inset-0 border-4 border-green-300 rounded-full animate-ping opacity-30"></div>
|
||||
<div className="absolute inset-2 border-2 border-green-400 rounded-full animate-ping opacity-40 animation-delay-500"></div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-4 animate-slide-down">
|
||||
<h1 className="text-4xl font-bold text-gray-800 dark:text-white mb-4 animate-slide-down">
|
||||
Join Coding Exam
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg animate-fade-in animate-delay-300">
|
||||
<p className="text-gray-600 dark:text-gray-300 text-lg animate-fade-in animate-delay-300">
|
||||
Enter the exam code to participate in the coding challenge
|
||||
</p>
|
||||
</div>
|
||||
@@ -528,7 +528,7 @@ Redirecting to exam interface...`)
|
||||
placeholder="Enter exam code (e.g., 3BPIBZ)"
|
||||
value={examId}
|
||||
onChange={(e) => setExamId(e.target.value.toUpperCase())}
|
||||
className="w-full p-4 border-2 border-gray-200 rounded-xl text-center font-mono text-2xl tracking-widest uppercase transition-all duration-300 focus:ring-4 focus:ring-green-200 focus:border-green-500 hover:border-green-300 bg-gray-50 hover:bg-white focus:bg-white relative group"
|
||||
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 rounded-xl text-center font-mono text-2xl tracking-widest uppercase text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-green-200 dark:focus:ring-green-800 focus:border-green-500 dark:focus:border-green-400 hover:border-green-300 dark:hover:border-green-500 bg-gray-50 dark:bg-gray-700 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 relative group"
|
||||
maxLength={6}
|
||||
/>
|
||||
{/* Input decorations */}
|
||||
@@ -546,7 +546,7 @@ Redirecting to exam interface...`)
|
||||
placeholder="Enter your name"
|
||||
value={participantName}
|
||||
onChange={(e) => setParticipantName(e.target.value)}
|
||||
className="w-full p-4 border-2 border-gray-200 rounded-xl text-lg transition-all duration-300 focus:ring-4 focus:ring-green-200 focus:border-green-500 hover:border-green-300 bg-gray-50 hover:bg-white focus:bg-white group"
|
||||
className="w-full p-4 border-2 border-gray-200 dark:border-gray-600 rounded-xl text-lg text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-300 focus:ring-4 focus:ring-green-200 dark:focus:ring-green-800 focus:border-green-500 dark:focus:border-green-400 hover:border-green-300 dark:hover:border-green-500 bg-gray-50 dark:bg-gray-700 hover:bg-white dark:hover:bg-gray-600 focus:bg-white dark:focus:bg-gray-700 group"
|
||||
/>
|
||||
{/* Name validation indicator */}
|
||||
{participantName.length > 2 && (
|
||||
@@ -579,7 +579,7 @@ Redirecting to exam interface...`)
|
||||
</button>
|
||||
|
||||
{/* Enhanced Debug Info */}
|
||||
<div className="text-sm text-gray-500 p-6 bg-gradient-to-r from-gray-50 to-green-50 rounded-xl animate-fade-in border border-gray-200 hover:border-green-300 transition-colors duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 p-6 bg-gradient-to-r from-gray-50 to-green-50 dark:from-gray-700 dark:to-green-800 rounded-xl animate-fade-in border border-gray-200 dark:border-gray-600 hover:border-green-300 dark:hover:border-green-500 transition-colors duration-300" style={{ animationDelay: '0.4s' }}>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"></div>
|
||||
<span className="font-semibold">Connection Status</span>
|
||||
@@ -605,7 +605,7 @@ Redirecting to exam interface...`)
|
||||
// Enhanced System Requirements Check
|
||||
if (!systemChecked) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-red-900 to-black text-white flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#3f3b77] dark:to-[#4a2f86] text-gray-900 dark:text-white flex items-center justify-center relative overflow-hidden animate-fade-in">
|
||||
{/* Animated warning elements */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-1/4 left-1/4 w-32 h-32 bg-red-500 rounded-full animate-pulse"></div>
|
||||
@@ -621,7 +621,7 @@ Redirecting to exam interface...`)
|
||||
<Shield className="w-6 h-6 text-yellow-400 opacity-40 animate-bounce" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/95 backdrop-blur-lg rounded-3xl p-12 max-w-2xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 border border-red-500/30 relative overflow-hidden group">
|
||||
<div className="bg-white dark:bg-[#22314a]/95 backdrop-blur-lg rounded-3xl p-12 max-w-2xl w-full transform animate-scale-in hover:scale-105 transition-all duration-500 border border-red-500/30 dark:border-red-400/30 relative overflow-hidden group">
|
||||
{/* Security-themed background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-900/20 to-yellow-900/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
@@ -638,7 +638,7 @@ Redirecting to exam interface...`)
|
||||
<h1 className="text-4xl font-bold mb-6 animate-slide-down">
|
||||
System Requirements Check
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 animate-fade-in animate-delay-300">
|
||||
<p className="text-xl text-gray-300 dark:text-gray-300 animate-fade-in animate-delay-300">
|
||||
Preparing secure exam environment
|
||||
</p>
|
||||
</div>
|
||||
@@ -648,7 +648,7 @@ Redirecting to exam interface...`)
|
||||
<Shield className="h-8 w-8 text-green-400 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<span className="text-lg font-medium">Fullscreen mode support</span>
|
||||
<p className="text-sm text-gray-400">Required for secure examination</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-400">Required for secure examination</p>
|
||||
</div>
|
||||
<CheckCircle className="h-6 w-6 text-green-400 animate-bounce" />
|
||||
</div>
|
||||
@@ -657,7 +657,7 @@ Redirecting to exam interface...`)
|
||||
<Lock className="h-8 w-8 text-yellow-400 animate-bounce" />
|
||||
<div className="flex-1">
|
||||
<span className="text-lg font-medium">Copy/paste will be disabled</span>
|
||||
<p className="text-sm text-gray-400">Prevents unauthorized assistance</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-400">Prevents unauthorized assistance</p>
|
||||
</div>
|
||||
<XCircle className="h-6 w-6 text-yellow-400 animate-pulse" />
|
||||
</div>
|
||||
@@ -666,7 +666,7 @@ Redirecting to exam interface...`)
|
||||
<AlertTriangle className="h-8 w-8 text-red-400 animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<span className="text-lg font-medium">Virtual environments will be detected</span>
|
||||
<p className="text-sm text-gray-400">Ensures exam integrity</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-400">Ensures exam integrity</p>
|
||||
</div>
|
||||
<Shield className="h-6 w-6 text-red-400 animate-bounce" />
|
||||
</div>
|
||||
@@ -691,12 +691,12 @@ Redirecting to exam interface...`)
|
||||
</button>
|
||||
|
||||
{/* Security notice */}
|
||||
<div className="mt-6 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded-xl animate-fade-in animate-delay-500">
|
||||
<div className="mt-6 p-4 bg-yellow-900/30 border border-yellow-500/50 rounded-xl animate-fade-in animate-delay-500 dark:bg-yellow-900/30 dark:border-yellow-500/50">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400 animate-pulse" />
|
||||
<span className="font-semibold text-yellow-300">Security Notice</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-200">
|
||||
<p className="text-sm text-yellow-200 dark:text-yellow-200">
|
||||
This exam uses advanced security measures. Browser restrictions will be enforced during the examination period.
|
||||
</p>
|
||||
</div>
|
||||
@@ -708,7 +708,7 @@ Redirecting to exam interface...`)
|
||||
|
||||
// Enhanced Main Exam Interface
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-slate-900 to-black text-white animate-fade-in relative overflow-hidden">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-[#1b3760] dark:via-[#24467d] dark:to-[#4a2f86] text-gray-900 dark:text-white animate-fade-in relative overflow-hidden">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute top-0 left-0 w-96 h-96 bg-blue-500 rounded-full mix-blend-overlay animate-blob"></div>
|
||||
@@ -772,11 +772,11 @@ Redirecting to exam interface...`)
|
||||
<div className="flex-1 h-1 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-lg text-gray-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
<p className="mb-6 text-lg text-gray-300 dark:text-gray-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
|
||||
Write a function that converts a string to uppercase.
|
||||
</p>
|
||||
|
||||
<div className="bg-black/50 p-6 rounded-xl transform transition-all duration-300 hover:bg-black/60 animate-slide-up border border-gray-600 hover:border-blue-500/50" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="bg-blue-950/35 p-6 rounded-xl transform transition-all duration-300 hover:bg-blue-900/40 animate-slide-up border border-blue-300/25 hover:border-blue-300/60" style={{ animationDelay: '0.2s' }}>
|
||||
<pre className="text-green-400 font-mono text-lg">
|
||||
{`def capitalize_string(text):
|
||||
# Your code here
|
||||
@@ -807,11 +807,11 @@ Redirecting to exam interface...`)
|
||||
|
||||
{/* Editor status indicators */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2 px-3 py-1 bg-green-900/30 rounded-full">
|
||||
<div className="flex items-center space-x-2 px-3 py-1 bg-green-900/30 rounded-full dark:bg-green-900/30">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-300">Ready</span>
|
||||
<span className="text-sm text-green-300 dark:text-green-300">Ready</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 font-mono">
|
||||
<div className="text-sm text-gray-400 dark:text-gray-400 font-mono">
|
||||
Lines: {code.split('\n').length} | Chars: {code.length}
|
||||
</div>
|
||||
</div>
|
||||
@@ -822,7 +822,7 @@ Redirecting to exam interface...`)
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="def capitalize_string(text):\n # Your code here\n pass"
|
||||
className="w-full h-80 bg-black/70 text-green-400 font-mono p-6 rounded-xl border-2 border-gray-600 resize-none transition-all duration-300 focus:border-green-500 focus:ring-4 focus:ring-green-500/20 hover:border-gray-500 animate-slide-up backdrop-blur-sm"
|
||||
className="w-full h-80 bg-blue-950/55 text-green-300 font-mono p-6 rounded-xl border-2 border-blue-300/25 resize-none transition-all duration-300 focus:border-green-400 focus:ring-4 focus:ring-green-500/20 hover:border-blue-300/50 animate-slide-up backdrop-blur-sm"
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
@@ -842,7 +842,7 @@ Redirecting to exam interface...`)
|
||||
</div>
|
||||
|
||||
{/* Line numbers overlay */}
|
||||
<div className="absolute left-2 top-6 text-gray-500 font-mono text-sm select-none pointer-events-none">
|
||||
<div className="absolute left-2 top-6 text-gray-500 dark:text-gray-600 font-mono text-sm select-none pointer-events-none">
|
||||
{Array.from({ length: code.split('\n').length }, (_, i) => (
|
||||
<div key={i} className="h-6 leading-6">
|
||||
{i + 1}
|
||||
@@ -897,14 +897,14 @@ Redirecting to exam interface...`)
|
||||
</div>
|
||||
|
||||
{/* Code statistics */}
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-400">
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-400 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
|
||||
<span>Python 3.9</span>
|
||||
<div className="w-2 h-2 bg-blue-400 dark:bg-blue-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-white dark:text-white">Python 3.9</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Syntax OK</span>
|
||||
<CheckCircle className="w-4 h-4 text-green-400 dark:text-green-400" />
|
||||
<span className="text-white dark:text-white">Syntax OK</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -925,18 +925,18 @@ Redirecting to exam interface...`)
|
||||
<div className="p-3 bg-yellow-600/20 rounded-xl animate-bounce">
|
||||
<Trophy className="h-8 w-8 text-yellow-400 animate-pulse" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold">Leaderboard</h3>
|
||||
<h3 className="text-2xl font-bold dark:text-white">Leaderboard</h3>
|
||||
<div className="flex-1 h-1 bg-gradient-to-r from-yellow-500 to-orange-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard stats */}
|
||||
<div className="mb-6 p-4 bg-black/30 rounded-xl border border-gray-600">
|
||||
<div className="mb-6 p-4 bg-blue-950/25 rounded-xl border border-blue-300/25 dark:border-blue-300/25">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-400">Total Participants</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-400">Total Participants</span>
|
||||
<span className="font-bold text-blue-400">{leaderboard.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-400">Completed</span>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-400">Completed</span>
|
||||
<span className="font-bold text-green-400">
|
||||
{leaderboard.filter(p => p.completed).length}
|
||||
</span>
|
||||
@@ -1003,7 +1003,7 @@ Redirecting to exam interface...`)
|
||||
|
||||
{/* Submission time */}
|
||||
{participant.submitted_at && (
|
||||
<div className="text-xs text-gray-400">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-400">
|
||||
Submitted: {new Date(participant.submitted_at).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
@@ -1013,7 +1013,7 @@ Redirecting to exam interface...`)
|
||||
<div className="absolute inset-0 -translate-x-full group-hover:translate-x-full bg-gradient-to-r from-transparent via-white/5 to-transparent transition-transform duration-700"></div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-8 text-gray-400 animate-pulse">
|
||||
<div className="text-center py-8 text-gray-400 dark:text-gray-400 animate-pulse">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No participants yet</p>
|
||||
</div>
|
||||
|
||||
@@ -224,26 +224,26 @@ fn main() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-800 border-b border-gray-700 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 shadow">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">OpenLearnX Real Compiler</h1>
|
||||
<p className="text-gray-400">Execute code in multiple programming languages with real output</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OpenLearnX Real Compiler</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Execute code in multiple programming languages with real output</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={testCompiler}
|
||||
className="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded flex items-center space-x-2"
|
||||
className="bg-purple-600 dark:bg-purple-700 hover:bg-purple-700 dark:hover:bg-purple-800 px-4 py-2 rounded flex items-center space-x-2 text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>Test Compiler</span>
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{languages.length} languages supported
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,14 +256,14 @@ fn main() {
|
||||
{/* Code Editor */}
|
||||
<div className="space-y-4">
|
||||
{/* Language Selector & Controls */}
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 shadow">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-bold">Code Editor</h2>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Code Editor</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<select
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => setSelectedLanguage(e.target.value)}
|
||||
className="bg-gray-700 text-white px-3 py-1 rounded border border-gray-600"
|
||||
className="bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white px-3 py-1 rounded border border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<option key={lang.id} value={lang.id}>
|
||||
@@ -281,14 +281,14 @@ fn main() {
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded cursor-pointer"
|
||||
className="bg-gray-600 dark:bg-gray-600 hover:bg-gray-700 dark:hover:bg-gray-700 px-3 py-1 rounded cursor-pointer text-white"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={downloadCode}
|
||||
className="bg-gray-600 hover:bg-gray-700 px-3 py-1 rounded"
|
||||
className="bg-gray-600 dark:bg-gray-600 hover:bg-gray-700 dark:hover:bg-gray-700 px-3 py-1 rounded text-white"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function LessonDetailPage() {
|
||||
const router = useRouter()
|
||||
const courseId = params?.courseId ?? ''
|
||||
const lessonId = params?.lessonId ?? ''
|
||||
const { user, firebaseUser, isLoading: isAuthLoading } = useAuth()
|
||||
const { user, isLoading: isAuthLoading } = useAuth()
|
||||
|
||||
const [course, setCourse] = useState<Course | null>(null)
|
||||
const [modules, setModules] = useState<Module[]>([])
|
||||
@@ -61,16 +61,16 @@ export default function LessonDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user && !firebaseUser) {
|
||||
if (!isAuthLoading && !user) {
|
||||
toast.error("Please login to view lessons.")
|
||||
router.replace("/")
|
||||
return
|
||||
}
|
||||
|
||||
if ((user || firebaseUser) && courseId) {
|
||||
if (user && courseId) {
|
||||
fetchCourseData()
|
||||
}
|
||||
}, [user, firebaseUser, isAuthLoading, router, courseId])
|
||||
}, [user, isAuthLoading, router, courseId])
|
||||
|
||||
const fetchCourseData = async () => {
|
||||
setLoading(true)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import { Loader2, Play, Clock, BookOpen, ChevronDown, ChevronRight, User, Users, Star, Award, TrendingUp, CheckCircle, ArrowRight } from "lucide-react"
|
||||
import { Loader2, Play, Clock, BookOpen, ChevronDown, ChevronRight, User, Users, Star, CheckCircle } from "lucide-react"
|
||||
import { toast } from "react-hot-toast"
|
||||
import api from "@/lib/api"
|
||||
import { useAuth } from "@/context/auth-context"
|
||||
@@ -44,7 +44,7 @@ type Lesson = {
|
||||
}
|
||||
|
||||
export default function CoursePage() {
|
||||
const { user, firebaseUser, isLoading: authLoading } = useAuth()
|
||||
const { user, isLoading: authLoading } = useAuth()
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const courseId = params?.courseId as string
|
||||
@@ -56,42 +56,49 @@ export default function CoursePage() {
|
||||
const [modulesLoading, setModulesLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Navigation state
|
||||
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null)
|
||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null)
|
||||
const [expandedModules, setExpandedModules] = useState<{ [moduleId: string]: boolean }>({})
|
||||
const [completed, setCompleted] = useState(false)
|
||||
|
||||
// Certificate Modal State
|
||||
const [showCertificateModal, setShowCertificateModal] = useState(false)
|
||||
|
||||
const logCourseActivity = async (action: "view" | "start" | "lesson_view", lessonId?: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token")
|
||||
await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/activity`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ action, lesson_id: lessonId }),
|
||||
})
|
||||
} catch {
|
||||
// Activity logging should not block course UX.
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user && !firebaseUser) {
|
||||
if (!authLoading && !user) {
|
||||
toast.error("Please login to view courses.")
|
||||
router.replace("/")
|
||||
return
|
||||
}
|
||||
if ((user || firebaseUser) && courseId) {
|
||||
if (user && courseId) {
|
||||
fetchCourseData()
|
||||
}
|
||||
}, [authLoading, user, firebaseUser, courseId, router])
|
||||
}, [authLoading, user, courseId, router])
|
||||
|
||||
const fetchCourseData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
console.log('🔍 Starting to fetch course data for:', courseId)
|
||||
|
||||
const courseResponse = await api.get<Course>(`/api/courses/${courseId}?t=${Date.now()}`)
|
||||
const courseData = courseResponse.data
|
||||
console.log('✅ Course data loaded:', courseData)
|
||||
setCourse(courseData)
|
||||
|
||||
try {
|
||||
const courseResponse = await api.get<Course>(`/api/courses/${courseId}?t=${Date.now()}`)
|
||||
setCourse(courseResponse.data)
|
||||
logCourseActivity("view")
|
||||
await fetchModulesAndLessons(courseId)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('❌ Error fetching course data:', err)
|
||||
setError(err.message || "Failed to load course data.")
|
||||
toast.error("Failed to load course data.")
|
||||
} finally {
|
||||
@@ -99,60 +106,37 @@ export default function CoursePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchModulesAndLessons = async (courseId: string) => {
|
||||
const fetchModulesAndLessons = async (id: string) => {
|
||||
setModulesLoading(true)
|
||||
|
||||
|
||||
try {
|
||||
console.log('🔍 Fetching modules for course:', courseId)
|
||||
|
||||
let modulesData = null
|
||||
let modulesResponse = null
|
||||
|
||||
// Use public endpoint for course page (not admin endpoint)
|
||||
try {
|
||||
modulesResponse = await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/modules`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (modulesResponse.ok) {
|
||||
modulesData = await modulesResponse.json()
|
||||
console.log('✅ Modules loaded from public endpoint:', modulesData)
|
||||
}
|
||||
} catch (publicError) {
|
||||
console.error('❌ Module endpoint failed')
|
||||
}
|
||||
|
||||
if (modulesData) {
|
||||
let modulesList: Module[] = []
|
||||
|
||||
if (modulesData.success && modulesData.modules && Array.isArray(modulesData.modules)) {
|
||||
modulesList = modulesData.modules
|
||||
} else if (modulesData.modules && Array.isArray(modulesData.modules)) {
|
||||
modulesList = modulesData.modules
|
||||
} else if (Array.isArray(modulesData)) {
|
||||
modulesList = modulesData
|
||||
} else if (modulesData.data && Array.isArray(modulesData.data)) {
|
||||
modulesList = modulesData.data
|
||||
}
|
||||
|
||||
modulesList = modulesList.sort((a, b) => a.order - b.order)
|
||||
|
||||
console.log('🔍 Processed modules list:', modulesList)
|
||||
setModules(modulesList)
|
||||
|
||||
if (modulesList.length > 0) {
|
||||
await fetchLessonsForAllModules(modulesList)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No modules data received')
|
||||
const modulesResponse = await fetch(`http://127.0.0.1:5000/api/courses/${id}/modules`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
if (!modulesResponse.ok) {
|
||||
setModules([])
|
||||
setLessons({})
|
||||
return
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in fetchModulesAndLessons:', error)
|
||||
|
||||
const modulesData = await modulesResponse.json()
|
||||
let modulesList: Module[] = []
|
||||
|
||||
if (modulesData.success && Array.isArray(modulesData.modules)) modulesList = modulesData.modules
|
||||
else if (Array.isArray(modulesData.modules)) modulesList = modulesData.modules
|
||||
else if (Array.isArray(modulesData)) modulesList = modulesData
|
||||
else if (Array.isArray(modulesData.data)) modulesList = modulesData.data
|
||||
|
||||
modulesList = modulesList.sort((a, b) => a.order - b.order)
|
||||
setModules(modulesList)
|
||||
|
||||
if (modulesList.length > 0) {
|
||||
await fetchLessonsForAllModules(modulesList)
|
||||
} else {
|
||||
setLessons({})
|
||||
}
|
||||
} catch {
|
||||
setModules([])
|
||||
setLessons({})
|
||||
} finally {
|
||||
@@ -163,112 +147,84 @@ export default function CoursePage() {
|
||||
const fetchLessonsForAllModules = async (modulesList: Module[]) => {
|
||||
const lessonsData: { [moduleId: string]: Lesson[] } = {}
|
||||
const expandedState: { [moduleId: string]: boolean } = {}
|
||||
|
||||
|
||||
for (const module of modulesList) {
|
||||
try {
|
||||
console.log('🔍 Fetching lessons for module:', module.id)
|
||||
|
||||
// Use public endpoint for course page (not admin endpoint)
|
||||
const lessonsResponse = await fetch(`http://127.0.0.1:5000/api/modules/${module.id}/lessons`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
if (lessonsResponse.ok) {
|
||||
const lessonData = await lessonsResponse.json()
|
||||
console.log(`✅ Lessons loaded for module ${module.id}:`, lessonData)
|
||||
|
||||
let lessonsList: Lesson[] = []
|
||||
if (lessonData.success && lessonData.lessons && Array.isArray(lessonData.lessons)) {
|
||||
lessonsList = lessonData.lessons
|
||||
} else if (lessonData.lessons && Array.isArray(lessonData.lessons)) {
|
||||
lessonsList = lessonData.lessons
|
||||
} else if (Array.isArray(lessonData)) {
|
||||
lessonsList = lessonData
|
||||
} else if (lessonData.data && Array.isArray(lessonData.data)) {
|
||||
lessonsList = lessonData.data
|
||||
}
|
||||
|
||||
lessonsList = lessonsList.sort((a, b) => a.order - b.order)
|
||||
lessonsData[module.id] = lessonsList
|
||||
|
||||
if (!selectedModuleId && lessonsList.length > 0) {
|
||||
expandedState[module.id] = true
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ No lessons found for module ${module.id}`)
|
||||
|
||||
if (!lessonsResponse.ok) {
|
||||
lessonsData[module.id] = []
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching lessons for module ${module.id}:`, error)
|
||||
|
||||
const lessonData = await lessonsResponse.json()
|
||||
let lessonsList: Lesson[] = []
|
||||
|
||||
if (lessonData.success && Array.isArray(lessonData.lessons)) lessonsList = lessonData.lessons
|
||||
else if (Array.isArray(lessonData.lessons)) lessonsList = lessonData.lessons
|
||||
else if (Array.isArray(lessonData)) lessonsList = lessonData
|
||||
else if (Array.isArray(lessonData.data)) lessonsList = lessonData.data
|
||||
|
||||
lessonsData[module.id] = lessonsList.sort((a, b) => a.order - b.order)
|
||||
if (!selectedModuleId && lessonsData[module.id].length > 0) expandedState[module.id] = true
|
||||
} catch {
|
||||
lessonsData[module.id] = []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setLessons(lessonsData)
|
||||
setExpandedModules(expandedState)
|
||||
|
||||
|
||||
if (!selectedModuleId && modulesList.length > 0) {
|
||||
const firstModule = modulesList[0]
|
||||
const firstModuleLessons = lessonsData[firstModule.id] || []
|
||||
|
||||
setSelectedModuleId(firstModule.id)
|
||||
if (firstModuleLessons.length > 0) {
|
||||
setSelectedLessonId(firstModuleLessons[0].id)
|
||||
}
|
||||
if (firstModuleLessons.length > 0) setSelectedLessonId(firstModuleLessons[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
function getEmbedUrl(url?: string): string | undefined {
|
||||
const getEmbedUrl = (url?: string): string | undefined => {
|
||||
if (!url) return undefined
|
||||
const regExp = /(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/))([^#&?]{11})/
|
||||
const match = url.match(regExp)
|
||||
if (match && match[1]) {
|
||||
return `https://www.youtube.com/embed/${match[1]}?rel=0&modestbranding=1`
|
||||
}
|
||||
if (match && match[1]) return `https://www.youtube.com/embed/${match[1]}?rel=0&modestbranding=1`
|
||||
return url
|
||||
}
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[moduleId]: !prev[moduleId]
|
||||
}))
|
||||
setExpandedModules((prev) => ({ ...prev, [moduleId]: !prev[moduleId] }))
|
||||
}
|
||||
|
||||
const selectLesson = (moduleId: string, lessonId: string) => {
|
||||
setSelectedModuleId(moduleId)
|
||||
setSelectedLessonId(lessonId)
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[moduleId]: true
|
||||
}))
|
||||
setExpandedModules((prev) => ({ ...prev, [moduleId]: true }))
|
||||
logCourseActivity("lesson_view", lessonId)
|
||||
}
|
||||
|
||||
const getCurrentLesson = (): Lesson | null => {
|
||||
if (!selectedModuleId || !selectedLessonId) return null
|
||||
const moduleLessons = lessons[selectedModuleId] || []
|
||||
return moduleLessons.find(lesson => lesson.id === selectedLessonId) || null
|
||||
return (lessons[selectedModuleId] || []).find((lesson) => lesson.id === selectedLessonId) || null
|
||||
}
|
||||
|
||||
const getAllLessons = (): Lesson[] => {
|
||||
const allLessons: Lesson[] = []
|
||||
modules.forEach(module => {
|
||||
const moduleLessons = lessons[module.id] || []
|
||||
allLessons.push(...moduleLessons)
|
||||
const all: Lesson[] = []
|
||||
modules.forEach((module) => {
|
||||
all.push(...(lessons[module.id] || []))
|
||||
})
|
||||
return allLessons
|
||||
return all
|
||||
}
|
||||
|
||||
const navigateLesson = (direction: 'prev' | 'next') => {
|
||||
const navigateLesson = (direction: "prev" | "next") => {
|
||||
const allLessons = getAllLessons()
|
||||
const currentIndex = allLessons.findIndex(lesson => lesson.id === selectedLessonId)
|
||||
|
||||
if (direction === 'prev' && currentIndex > 0) {
|
||||
const currentIndex = allLessons.findIndex((lesson) => lesson.id === selectedLessonId)
|
||||
|
||||
if (direction === "prev" && currentIndex > 0) {
|
||||
const prevLesson = allLessons[currentIndex - 1]
|
||||
selectLesson(prevLesson.module_id, prevLesson.id)
|
||||
} else if (direction === 'next' && currentIndex < allLessons.length - 1) {
|
||||
} else if (direction === "next" && currentIndex < allLessons.length - 1) {
|
||||
const nextLesson = allLessons[currentIndex + 1]
|
||||
selectLesson(nextLesson.module_id, nextLesson.id)
|
||||
}
|
||||
@@ -284,37 +240,35 @@ export default function CoursePage() {
|
||||
return allLessons.length > 0 && allLessons[allLessons.length - 1].id === selectedLessonId
|
||||
}
|
||||
|
||||
const markComplete = () => {
|
||||
const markComplete = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token")
|
||||
if (selectedLessonId) {
|
||||
await fetch(`http://127.0.0.1:5000/api/courses/${courseId}/lessons/${selectedLessonId}/complete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Keep UX smooth even if completion log write fails.
|
||||
}
|
||||
setCompleted(true)
|
||||
setShowCertificateModal(true)
|
||||
}
|
||||
|
||||
const getTotalLessons = () => {
|
||||
return Object.values(lessons).reduce((total, moduleLessons) => total + moduleLessons.length, 0)
|
||||
}
|
||||
const getTotalLessons = () => Object.values(lessons).reduce((total, moduleLessons) => total + moduleLessons.length, 0)
|
||||
|
||||
const currentLesson = getCurrentLesson()
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center relative overflow-hidden">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
|
||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-yellow-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-1000"></div>
|
||||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse animation-delay-2000"></div>
|
||||
</div>
|
||||
<div className="text-center z-10">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-16 w-16 animate-spin text-white mx-auto mb-6 drop-shadow-lg" />
|
||||
<div className="absolute inset-0 h-16 w-16 border-4 border-transparent border-t-purple-400 rounded-full animate-ping mx-auto"></div>
|
||||
</div>
|
||||
<p className="text-xl text-white font-semibold tracking-wide animate-pulse">Loading your learning journey...</p>
|
||||
<div className="mt-4 flex justify-center space-x-1">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-bounce animation-delay-200"></div>
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-bounce animation-delay-400"></div>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600 mx-auto mb-3" />
|
||||
<p className="text-gray-700 dark:text-gray-300">Loading course...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -322,21 +276,16 @@ export default function CoursePage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-red-900 via-pink-900 to-purple-900 flex items-center justify-center p-4">
|
||||
<div className="text-center max-w-md mx-auto px-6">
|
||||
<div className="bg-white/10 backdrop-blur-lg border border-red-300/30 rounded-3xl p-10 shadow-2xl animate-bounce">
|
||||
<div className="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Oops! Something went wrong</h2>
|
||||
<p className="text-red-200 mb-8 leading-relaxed">{error}</p>
|
||||
<button
|
||||
onClick={fetchCourseData}
|
||||
className="px-8 py-4 bg-gradient-to-r from-red-500 to-pink-500 text-white rounded-2xl hover:from-red-600 hover:to-pink-600 shadow-lg transition-all duration-300 transform hover:scale-105 font-semibold text-lg"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-red-200 bg-white dark:bg-gray-800 p-6 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Unable to load course</h2>
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-300">{error}</p>
|
||||
<button
|
||||
onClick={fetchCourseData}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -344,214 +293,115 @@ export default function CoursePage() {
|
||||
|
||||
if (!course) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-800 to-gray-900 flex items-center justify-center p-4">
|
||||
<div className="text-center max-w-sm bg-white/10 backdrop-blur-lg rounded-3xl shadow-2xl p-10 animate-fadeIn">
|
||||
<div className="w-24 h-24 bg-gray-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce">
|
||||
<span className="text-4xl">🔍</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Course Not Found</h2>
|
||||
<p className="text-gray-300 leading-relaxed">The course you're looking for doesn't exist or may have been removed.</p>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-gray-200 bg-white dark:bg-gray-800 p-6 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Course not found</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">This course is unavailable or was removed.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50">
|
||||
{/* Animated Background Elements */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-96 h-96 bg-yellow-300 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-float animation-delay-2000"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white/80 backdrop-blur-lg shadow-xl border-b border-purple-200 sticky top-0 z-50">
|
||||
<div className="w-full px-6 sm:px-10 lg:px-16 xl:px-20">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex items-center space-x-6 animate-slideInLeft">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-purple-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg transform hover:scale-110 transition-transform duration-300">
|
||||
<span className="text-white font-extrabold text-2xl">OL</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent tracking-tight">{course.title}</h1>
|
||||
<p className="text-sm text-purple-700 font-semibold tracking-wide">by {course.mentor}</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="w-full px-6 sm:px-8 lg:px-12 py-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">{course.title}</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">by {course.mentor}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{modules.length} modules</span>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm text-purple-700 animate-slideInRight">
|
||||
<div className="flex items-center space-x-2 bg-purple-100 px-4 py-2 rounded-full">
|
||||
<BookOpen className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold">{modules.length} modules</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-indigo-100 px-4 py-2 rounded-full">
|
||||
<Play className="w-5 h-5 text-indigo-600" />
|
||||
<span className="font-semibold">{getTotalLessons()} lessons</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-pink-100 px-4 py-2 rounded-full">
|
||||
<Users className="w-5 h-5 text-pink-600" />
|
||||
<span className="font-semibold">{course.students.toLocaleString()} students</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
|
||||
<Play className="w-4 h-4" />
|
||||
<span>{getTotalLessons()} lessons</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1.5">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{course.students.toLocaleString()} students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="w-full px-6 sm:px-10 lg:px-16 xl:px-20 py-12 grid grid-cols-1 lg:grid-cols-5 gap-12 relative z-10">
|
||||
|
||||
{/* Sidebar - Now takes up 2 columns on large screens */}
|
||||
<aside className="lg:col-span-2 animate-slideInLeft">
|
||||
<div className="bg-white/80 backdrop-blur-lg rounded-3xl shadow-2xl border border-purple-200 p-10 sticky top-28">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent">Course Content</h2>
|
||||
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center">
|
||||
<BookOpen className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<main className="w-full px-6 sm:px-8 lg:px-12 py-6 grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<aside className="lg:col-span-2">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 sticky top-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Course Content</h2>
|
||||
|
||||
{/* Enhanced Progress Bar */}
|
||||
<div className="mb-8 p-4 bg-gradient-to-r from-purple-50 to-indigo-50 rounded-2xl border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-purple-700">Progress</span>
|
||||
<span className="text-sm font-bold text-indigo-600">25%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-purple-500 to-indigo-500 h-3 rounded-full transition-all duration-1000 ease-out animate-pulse" style={{width: '25%'}}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info - Enhanced */}
|
||||
<div className="mb-8 p-5 bg-gradient-to-r from-blue-50 to-cyan-50 border border-blue-200 rounded-2xl animate-fadeIn">
|
||||
<h3 className="text-sm font-bold text-blue-800 mb-4 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
🔍 Debug Info:
|
||||
</h3>
|
||||
<div className="text-xs space-y-2 text-blue-700">
|
||||
<p><strong>Course ID:</strong> {courseId}</p>
|
||||
<p><strong>Modules Loaded:</strong> {modules.length}</p>
|
||||
<p><strong>Total Lessons:</strong> {getTotalLessons()}</p>
|
||||
<p><strong>Modules Loading:</strong> {modulesLoading ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Selected Module:</strong> {selectedModuleId || 'None'}</p>
|
||||
<p><strong>Selected Lesson:</strong> {currentLesson?.title || 'None'}</p>
|
||||
<p><strong>Expanded Modules:</strong> {Object.keys(expandedModules).length}</p>
|
||||
</div>
|
||||
{modules.length > 0 && (
|
||||
<details className="mt-4 border-t border-blue-200 pt-4">
|
||||
<summary className="text-xs cursor-pointer text-blue-600 font-semibold hover:text-blue-800 transition-colors">Show Raw Data</summary>
|
||||
<pre className="mt-3 text-xs p-4 bg-white rounded-xl shadow max-h-40 overflow-auto">
|
||||
{JSON.stringify({ modules, lessons }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{modulesLoading && (
|
||||
<div className="text-center py-10 animate-pulse">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-purple-500 mx-auto mb-4" />
|
||||
<p className="text-lg text-purple-700 font-semibold">Loading modules...</p>
|
||||
<div className="text-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">Loading modules...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Modules State */}
|
||||
{!modulesLoading && modules.length === 0 && (
|
||||
<div className="text-center py-8 animate-bounce">
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border border-yellow-300 rounded-2xl p-6 text-yellow-800">
|
||||
<div className="w-16 h-16 bg-yellow-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl">📚</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-3">No Modules Found</h3>
|
||||
<p className="text-sm mb-4 leading-relaxed">
|
||||
This could mean:<br />
|
||||
• No modules created yet<br />
|
||||
• API endpoint issues<br />
|
||||
• Course ID mismatch
|
||||
</p>
|
||||
<button
|
||||
onClick={() => fetchModulesAndLessons(courseId)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-yellow-500 to-orange-500 rounded-2xl text-white font-bold hover:from-yellow-600 hover:to-orange-600 transition-all duration-300 transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
Retry Loading Modules
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-5 text-center">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">No content available yet</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Lessons for this course have not been published.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => fetchModulesAndLessons(courseId)}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modules List */}
|
||||
{!modulesLoading && modules.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{modules.map((module, index) => (
|
||||
<div key={module.id} className="border border-purple-200 rounded-2xl overflow-hidden shadow-lg bg-white/60 backdrop-blur-sm hover:shadow-xl transition-all duration-300 animate-fadeInUp" style={{animationDelay: `${index * 100}ms`}}>
|
||||
{/* Module Header */}
|
||||
<div key={module.id} className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleModule(module.id)}
|
||||
className={`w-full px-6 py-5 text-left hover:bg-gradient-to-r hover:from-purple-50 hover:to-indigo-50 flex items-center justify-between transition-all duration-300 ${
|
||||
selectedModuleId === module.id ? 'bg-gradient-to-r from-purple-100 to-indigo-100 border-purple-300' : 'bg-white/80'
|
||||
className={`w-full px-4 py-3 text-left flex items-center justify-between ${
|
||||
selectedModuleId === module.id ? "bg-blue-50 dark:bg-blue-900/20" : "bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="flex-shrink-0 w-10 h-10 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full flex items-center justify-center font-bold text-sm shadow-lg transform hover:scale-110 transition-transform duration-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="font-bold text-purple-900 truncate text-lg">{module.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-purple-600 mt-2 ml-14 flex items-center">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{(lessons[module.id]?.length ?? 0) + (lessons[module.id]?.length === 1 ? ' lesson' : ' lessons')}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{index + 1}. {module.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{(lessons[module.id]?.length || 0)} lessons
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-4">
|
||||
<div className={`transform transition-transform duration-300 ${expandedModules[module.id] ? 'rotate-180' : ''}`}>
|
||||
{expandedModules[module.id] ? (
|
||||
<ChevronDown className="w-6 h-6 text-purple-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-6 h-6 text-purple-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedModules[module.id] ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Lessons */}
|
||||
{expandedModules[module.id] && (
|
||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border-t border-purple-200 animate-slideDown">
|
||||
{lessons[module.id] && lessons[module.id].length > 0 ? (
|
||||
lessons[module.id].map((lesson, lessonIndex) => (
|
||||
<div className="border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/50">
|
||||
{(lessons[module.id] || []).length > 0 ? (
|
||||
(lessons[module.id] || []).map((lesson) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => selectLesson(module.id, lesson.id)}
|
||||
className={`w-full px-8 py-4 text-left hover:bg-gradient-to-r hover:from-purple-100 hover:to-indigo-100 transition-all duration-300 border-l-4 group ${
|
||||
className={`w-full px-4 py-3 text-left border-l-2 ${
|
||||
selectedLessonId === lesson.id
|
||||
? 'border-purple-500 bg-gradient-to-r from-purple-100 to-indigo-100 text-purple-900 font-bold shadow-inner'
|
||||
: 'border-transparent text-purple-700 hover:border-purple-300'
|
||||
? "border-blue-600 bg-blue-50 dark:bg-blue-900/20"
|
||||
: "border-transparent hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-xs transition-all duration-300 group-hover:scale-110 ${
|
||||
selectedLessonId === lesson.id
|
||||
? 'bg-gradient-to-r from-purple-500 to-indigo-500 text-white shadow-lg'
|
||||
: 'bg-purple-200 text-purple-700 group-hover:bg-purple-300'
|
||||
}`}>
|
||||
<Play className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-semibold">{lesson.title}</p>
|
||||
{lesson.duration && (
|
||||
<p className={`text-xs flex items-center mt-1 ${
|
||||
selectedLessonId === lesson.id ? 'text-purple-700 font-semibold' : 'text-purple-500'
|
||||
}`}>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
{lesson.duration}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className={`w-4 h-4 transition-all duration-300 ${
|
||||
selectedLessonId === lesson.id ? 'text-purple-600 transform scale-110' : 'text-transparent group-hover:text-purple-400'
|
||||
}`} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{lesson.title}</p>
|
||||
{lesson.duration && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" /> {lesson.duration}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-8 py-6 text-purple-600 text-sm italic text-center">No lessons in this module</p>
|
||||
<p className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">No lessons in this module.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -562,14 +412,12 @@ export default function CoursePage() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content - Now takes up 3 columns on large screens for full width */}
|
||||
<section className="lg:col-span-3 animate-slideInRight">
|
||||
<div className="bg-white/80 backdrop-blur-lg rounded-3xl shadow-2xl border border-purple-200 overflow-hidden">
|
||||
<section className="lg:col-span-3">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
|
||||
{currentLesson ? (
|
||||
<>
|
||||
{/* Video Player */}
|
||||
{(currentLesson.embed_url || currentLesson.video_url) && (
|
||||
<div className="aspect-video bg-black rounded-t-3xl overflow-hidden relative group">
|
||||
<div className="aspect-video bg-black">
|
||||
<iframe
|
||||
src={getEmbedUrl(currentLesson.embed_url || currentLesson.video_url)}
|
||||
title={currentLesson.title}
|
||||
@@ -577,155 +425,84 @@ export default function CoursePage() {
|
||||
className="w-full h-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lesson Content */}
|
||||
<div className="p-16">
|
||||
{/* Lesson Header */}
|
||||
<div className="mb-12 animate-fadeInUp">
|
||||
<div className="flex items-center text-purple-600 space-x-4 mb-6">
|
||||
<div className="flex items-center space-x-2 bg-purple-100 px-6 py-3 rounded-full">
|
||||
<User className="w-6 h-6" />
|
||||
<span className="font-bold text-lg">{course.mentor}</span>
|
||||
</div>
|
||||
<span className="text-purple-300">•</span>
|
||||
<span className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-6 py-3 rounded-full text-lg font-bold uppercase tracking-widest shadow-lg">
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-6xl font-extrabold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent leading-tight mb-6 drop-shadow-sm">{currentLesson.title}</h1>
|
||||
{currentLesson.duration && (
|
||||
<div className="flex items-center text-purple-600 space-x-3 text-xl font-semibold">
|
||||
<div className="flex items-center space-x-2 bg-purple-100 px-6 py-3 rounded-full">
|
||||
<Clock className="w-6 h-6" />
|
||||
<span>{currentLesson.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1">
|
||||
<User className="w-4 h-4" /> {course.mentor}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-3 py-1 font-medium">
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Lesson Description */}
|
||||
<h2 className="text-3xl font-semibold text-gray-900 dark:text-white mb-3">{currentLesson.title}</h2>
|
||||
|
||||
{currentLesson.description && (
|
||||
<section className="mb-16 animate-fadeInUp animation-delay-200">
|
||||
<h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent mb-8 border-b-2 border-purple-200 pb-4">
|
||||
About this lesson
|
||||
</h2>
|
||||
<article className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-3xl p-10 text-purple-900 prose max-w-none shadow-inner border border-purple-200 text-lg leading-relaxed">
|
||||
{currentLesson.description}
|
||||
</article>
|
||||
</section>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">About this lesson</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{currentLesson.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lesson Content */}
|
||||
{currentLesson.content && (
|
||||
<section className="mb-16 animate-fadeInUp animation-delay-400">
|
||||
<h2 className="text-4xl font-bold bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text text-transparent mb-8 border-b-2 border-purple-200 pb-4">
|
||||
Lesson Content
|
||||
</h2>
|
||||
<article className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-3xl p-10 text-purple-900 prose max-w-none whitespace-pre-line shadow-inner border border-purple-200 text-lg leading-relaxed">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">Lesson notes</h3>
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-4 text-gray-800 dark:text-gray-200 whitespace-pre-line">
|
||||
{currentLesson.content}
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between items-center pt-12 border-t-2 border-purple-200 animate-fadeInUp animation-delay-600">
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
|
||||
<button
|
||||
onClick={() => navigateLesson('prev')}
|
||||
onClick={() => navigateLesson("prev")}
|
||||
disabled={isFirstLesson()}
|
||||
className="px-12 py-5 bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 rounded-3xl hover:from-gray-200 hover:to-gray-300 disabled:opacity-50 disabled:cursor-not-allowed font-bold transition-all duration-300 transform hover:scale-105 shadow-lg text-xl"
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 disabled:opacity-50 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
← Previous Lesson
|
||||
Previous
|
||||
</button>
|
||||
|
||||
{!isLastLesson() ? (
|
||||
<button
|
||||
onClick={() => navigateLesson('next')}
|
||||
className="px-12 py-5 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-3xl hover:from-purple-700 hover:to-indigo-700 font-bold transition-all duration-300 transform hover:scale-105 shadow-xl text-xl"
|
||||
onClick={() => navigateLesson("next")}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
Next Lesson →
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={markComplete}
|
||||
disabled={completed}
|
||||
className={`px-12 py-5 rounded-3xl font-bold transition-all duration-300 transform hover:scale-105 shadow-xl text-xl ${
|
||||
completed
|
||||
? "bg-gradient-to-r from-green-500 to-emerald-500 text-white cursor-not-allowed shadow-inner"
|
||||
: "bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:from-purple-700 hover:to-pink-700"
|
||||
}`}
|
||||
className={`px-4 py-2 rounded-lg text-white ${completed ? "bg-green-600" : "bg-blue-600 hover:bg-blue-700"}`}
|
||||
>
|
||||
{completed ? "✓ Course Completed" : "Mark as Complete"}
|
||||
{completed ? "Completed" : "Mark as complete"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Completion Message */}
|
||||
{completed && !showCertificateModal && (
|
||||
<div className="mt-16 bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-300 rounded-3xl p-12 text-center shadow-2xl animate-bounce">
|
||||
<div className="text-green-700">
|
||||
<div className="text-8xl mb-8 animate-pulse">🎉</div>
|
||||
<h3 className="text-4xl font-extrabold mb-6 bg-gradient-to-r from-green-600 to-emerald-600 bg-clip-text text-transparent">Congratulations!</h3>
|
||||
<p className="mb-10 text-green-800 font-semibold text-2xl">
|
||||
You have successfully completed this course!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCertificateModal(true)}
|
||||
className="px-16 py-6 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-3xl hover:from-green-700 hover:to-emerald-700 transition-all duration-300 transform hover:scale-105 font-bold text-xl shadow-xl"
|
||||
>
|
||||
Get Your Certificate 🏆
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Course Overview */
|
||||
<div className="p-20 text-center max-w-5xl mx-auto text-purple-900 animate-fadeIn">
|
||||
<h1 className="text-7xl font-extrabold mb-10 bg-gradient-to-r from-purple-600 via-pink-600 to-indigo-600 bg-clip-text text-transparent drop-shadow-lg">{course.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-8 mb-16 text-purple-700 font-bold text-xl">
|
||||
<div className="flex items-center space-x-4 bg-purple-100 px-8 py-4 rounded-full shadow-lg transform hover:scale-105 transition-transform duration-300">
|
||||
<User className="w-8 h-8" />
|
||||
<span>by {course.mentor}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 bg-yellow-100 px-8 py-4 rounded-full shadow-lg transform hover:scale-105 transition-transform duration-300">
|
||||
<Star className="w-8 h-8 text-yellow-500" />
|
||||
<span>4.8 Rating</span>
|
||||
</div>
|
||||
<span className="bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-8 py-4 rounded-full text-xl uppercase font-extrabold tracking-widest shadow-lg transform hover:scale-105 transition-transform duration-300">
|
||||
<div className="p-8 text-center">
|
||||
<h2 className="text-3xl font-semibold text-gray-900 dark:text-white">{course.title}</h2>
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-3 text-sm">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-700 px-3 py-1 text-gray-700 dark:text-gray-300">
|
||||
<User className="w-4 h-4" /> by {course.mentor}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 dark:bg-yellow-900/30 px-3 py-1 text-yellow-700 dark:text-yellow-300">
|
||||
<Star className="w-4 h-4" /> 4.8 rating
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-3 py-1 text-blue-700 dark:text-blue-300">
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-3xl max-w-5xl mx-auto mb-16 leading-relaxed tracking-wide text-purple-800">{course.description}</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-16 mb-16">
|
||||
<div className="text-center transform hover:scale-110 transition-transform duration-300">
|
||||
<div className="w-32 h-32 bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
|
||||
<span className="text-5xl font-extrabold text-white">{modules.length}</span>
|
||||
</div>
|
||||
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Modules</div>
|
||||
</div>
|
||||
<div className="text-center transform hover:scale-110 transition-transform duration-300">
|
||||
<div className="w-32 h-32 bg-gradient-to-r from-pink-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
|
||||
<span className="text-5xl font-extrabold text-white">{getTotalLessons()}</span>
|
||||
</div>
|
||||
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Lessons</div>
|
||||
</div>
|
||||
<div className="text-center transform hover:scale-110 transition-transform duration-300">
|
||||
<div className="w-32 h-32 bg-gradient-to-r from-indigo-500 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-xl">
|
||||
<span className="text-5xl font-extrabold text-white">{course.students.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="uppercase text-purple-700 font-bold tracking-wide text-xl">Students</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-6 text-gray-700 dark:text-gray-300 max-w-3xl mx-auto">{course.description}</p>
|
||||
|
||||
{(course.embed_url || course.video_url) && (
|
||||
<div className="aspect-video rounded-3xl overflow-hidden shadow-2xl mx-auto max-w-6xl bg-black border-4 border-purple-600 mb-16 transform hover:scale-105 transition-transform duration-500">
|
||||
<div className="mt-8 aspect-video rounded-xl overflow-hidden border border-gray-200 dark:border-gray-600 bg-black max-w-4xl mx-auto">
|
||||
<iframe
|
||||
src={getEmbedUrl(course.embed_url || course.video_url)}
|
||||
title={course.title}
|
||||
@@ -741,23 +518,18 @@ export default function CoursePage() {
|
||||
onClick={() => {
|
||||
const firstModule = modules[0]
|
||||
const firstLessons = lessons[firstModule?.id] || []
|
||||
if (firstLessons.length > 0) {
|
||||
if (firstModule && firstLessons.length > 0) {
|
||||
logCourseActivity("start")
|
||||
selectLesson(firstModule.id, firstLessons[0].id)
|
||||
}
|
||||
}}
|
||||
className="mt-12 px-20 py-8 bg-gradient-to-r from-purple-600 via-pink-600 to-indigo-600 text-white rounded-3xl hover:from-purple-700 hover:via-pink-700 hover:to-indigo-700 font-extrabold text-3xl shadow-2xl transition-all duration-300 transform hover:scale-110 hover:shadow-purple-500/25"
|
||||
className="mt-8 px-6 py-3 rounded-lg bg-blue-600 text-white hover:bg-blue-700"
|
||||
>
|
||||
🚀 Start Learning Journey
|
||||
Start learning
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-orange-50 border-2 border-yellow-300 rounded-3xl p-12 text-yellow-800 text-2xl font-bold max-w-lg mx-auto shadow-xl">
|
||||
<div className="w-24 h-24 bg-yellow-500 rounded-full flex items-center justify-center mx-auto mb-8">
|
||||
<span className="text-4xl">🚧</span>
|
||||
</div>
|
||||
<h3 className="text-3xl mb-6">Coming Soon</h3>
|
||||
<p className="font-normal text-yellow-700 text-xl">
|
||||
Amazing lessons are being crafted for this course. Check back soon!
|
||||
</p>
|
||||
<div className="mt-8 rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 p-5 max-w-xl mx-auto">
|
||||
<p className="text-gray-700 dark:text-gray-300">Lessons are not published yet for this course.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -766,7 +538,6 @@ export default function CoursePage() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Certificate Modal */}
|
||||
{showCertificateModal && course && (
|
||||
<CertificateModal
|
||||
isOpen={showCertificateModal}
|
||||
@@ -774,49 +545,10 @@ export default function CoursePage() {
|
||||
courseTitle={course.title}
|
||||
courseMentor={course.mentor}
|
||||
courseId={course.id}
|
||||
userId={user?.uid || firebaseUser?.uid || 'anonymous'}
|
||||
walletId={user?.wallet || firebaseUser?.uid || 'no-wallet'}
|
||||
userId={user?.id || "anonymous"}
|
||||
walletId={user?.wallet_address || "no-wallet"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Custom CSS for animations */}
|
||||
<style jsx>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes slideInLeft {
|
||||
from { opacity: 0; transform: translateX(-50px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes slideInRight {
|
||||
from { opacity: 0; transform: translateX(50px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; max-height: 0; }
|
||||
to { opacity: 1; max-height: 500px; }
|
||||
}
|
||||
.animate-float { animation: float 6s ease-in-out infinite; }
|
||||
.animate-fadeIn { animation: fadeIn 1s ease-out; }
|
||||
.animate-fadeInUp { animation: fadeInUp 0.8s ease-out; }
|
||||
.animate-slideInLeft { animation: slideInLeft 0.8s ease-out; }
|
||||
.animate-slideInRight { animation: slideInRight 0.8s ease-out; }
|
||||
.animate-slideDown { animation: slideDown 0.3s ease-out; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-600 { animation-delay: 0.6s; }
|
||||
.animation-delay-1000 { animation-delay: 1s; }
|
||||
.animation-delay-2000 { animation-delay: 2s; }
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+631
-172
@@ -3,6 +3,7 @@
|
||||
import { useAuth } from "@/context/auth-context"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "react-hot-toast"
|
||||
import {
|
||||
User,
|
||||
LogOut,
|
||||
@@ -19,46 +20,249 @@ import {
|
||||
Activity,
|
||||
Edit3,
|
||||
Save,
|
||||
X
|
||||
X,
|
||||
Loader2,
|
||||
Github,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Link2,
|
||||
Flame,
|
||||
Upload
|
||||
} from "lucide-react"
|
||||
import api from "@/lib/api"
|
||||
|
||||
type ActivityData = {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
completed_at: string
|
||||
timestamp_utc?: string
|
||||
points_earned?: number
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, firebaseUser, walletConnected, logout, authMethod } = useAuth()
|
||||
const { user, walletConnected, logout, authMethod } = useAuth()
|
||||
const router = useRouter()
|
||||
const normalizedRole = String(user?.role || 'student').toLowerCase()
|
||||
const roleLabel = normalizedRole === 'admin' ? 'Admin' : normalizedRole === 'instructor' ? 'Instructor' : 'Student'
|
||||
const roleBadgeClass =
|
||||
normalizedRole === 'admin'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
: normalizedRole === 'instructor'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200'
|
||||
: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false)
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true)
|
||||
const [isEditingSocial, setIsEditingSocial] = useState(false)
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false)
|
||||
const [showAllActivities, setShowAllActivities] = useState(false)
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityData[]>([])
|
||||
const [profileData, setProfileData] = useState({
|
||||
name: user?.name || '',
|
||||
bio: user?.bio || '',
|
||||
avatar: user?.avatar || ''
|
||||
})
|
||||
const [socialData, setSocialData] = useState({
|
||||
github: '',
|
||||
linkedin: '',
|
||||
twitter: ''
|
||||
})
|
||||
|
||||
const [stats, setStats] = useState({
|
||||
coursesCompleted: 12,
|
||||
totalXP: 2450,
|
||||
currentStreak: 7,
|
||||
rank: 156,
|
||||
certificatesEarned: 3,
|
||||
hoursLearned: 45
|
||||
coursesCompleted: 0,
|
||||
totalXP: 0,
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
rank: 0,
|
||||
certificatesEarned: 0,
|
||||
hoursLearned: 0,
|
||||
lastActiveDate: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Fetch real stats from API
|
||||
useEffect(() => {
|
||||
if (!user && !firebaseUser) {
|
||||
if (!user) {
|
||||
router.replace("/auth/login")
|
||||
return
|
||||
}
|
||||
}, [user, firebaseUser, router])
|
||||
|
||||
fetchRealStats()
|
||||
}, [user, router])
|
||||
|
||||
const handleProfileUpdate = async () => {
|
||||
const fetchRealStats = async () => {
|
||||
setIsLoadingStats(true)
|
||||
try {
|
||||
// Here you would call your API to update profile
|
||||
// await updateProfile(profileData)
|
||||
setIsEditingProfile(false)
|
||||
console.log("Profile updated:", profileData)
|
||||
} catch (error) {
|
||||
console.error("Failed to update profile:", error)
|
||||
const [statsResponse, activityResponse] = await Promise.all([
|
||||
api.get("/api/dashboard/comprehensive-stats"),
|
||||
api.get("/api/dashboard/recent-activity"),
|
||||
])
|
||||
|
||||
if (statsResponse.data.success && statsResponse.data.data) {
|
||||
const data = statsResponse.data.data
|
||||
const streakData = data.streak_data || {}
|
||||
setStats({
|
||||
coursesCompleted: data.courses_completed || 0,
|
||||
totalXP: data.total_xp || 0,
|
||||
currentStreak: streakData.current_streak || 0,
|
||||
bestStreak: streakData.best_streak || 0,
|
||||
rank: data.global_rank || 0,
|
||||
certificatesEarned: data.blockchain?.certificates || 0,
|
||||
hoursLearned: Math.round(data.learning_analytics?.time_spent_hours || 0),
|
||||
lastActiveDate: data.last_active_date || new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
if (activityResponse.data?.success && Array.isArray(activityResponse.data?.data)) {
|
||||
setRecentActivity(activityResponse.data.data)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch dashboard stats:", error)
|
||||
// Keep default values if fetch fails
|
||||
toast.error("Failed to load dashboard data")
|
||||
} finally {
|
||||
setIsLoadingStats(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user && !firebaseUser) {
|
||||
const handleSettingsClick = () => {
|
||||
setIsEditingSocial(false)
|
||||
setIsEditingProfile(true)
|
||||
const el = document.getElementById("profile-card")
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
}
|
||||
}
|
||||
|
||||
const activityIconConfig = (activityType: string) => {
|
||||
const t = String(activityType || "").toLowerCase()
|
||||
if (t.includes("course")) return { icon: BookOpen, bgColor: "bg-green-100", textColor: "text-green-600" }
|
||||
if (t.includes("quiz")) return { icon: Award, bgColor: "bg-blue-100", textColor: "text-blue-600" }
|
||||
if (t.includes("streak")) return { icon: Flame, bgColor: "bg-orange-100", textColor: "text-orange-600" }
|
||||
if (t.includes("rank")) return { icon: TrendingUp, bgColor: "bg-purple-100", textColor: "text-purple-600" }
|
||||
if (t.includes("account") || t.includes("auth")) return { icon: Settings, bgColor: "bg-indigo-100", textColor: "text-indigo-600" }
|
||||
return { icon: Activity, bgColor: "bg-slate-100", textColor: "text-slate-600" }
|
||||
}
|
||||
|
||||
const isPlaceholderActivity = (item: ActivityData) => {
|
||||
const text = `${item.title || ""} ${item.description || ""}`.toLowerCase()
|
||||
const fakeMarkers = [
|
||||
"completed react fundamentals",
|
||||
"scored 95% on javascript quiz",
|
||||
"7-day learning streak achieved",
|
||||
"moved up 5 positions in leaderboard",
|
||||
]
|
||||
return fakeMarkers.some((marker) => text.includes(marker))
|
||||
}
|
||||
|
||||
const realActivities = recentActivity.filter((item) => !isPlaceholderActivity(item))
|
||||
const visibleActivities = showAllActivities ? realActivities : realActivities.slice(0, 6)
|
||||
|
||||
const handleProfileUpdate = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
||||
if (!token) {
|
||||
toast.error("Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await api.post(
|
||||
"/api/auth/profile/update",
|
||||
{
|
||||
name: profileData.name,
|
||||
bio: profileData.bio,
|
||||
avatar: profileData.avatar
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
setIsEditingProfile(false)
|
||||
toast.success("Profile updated successfully")
|
||||
// Update local user context if available
|
||||
console.log("Profile updated:", response.data.user)
|
||||
} else {
|
||||
toast.error(response.data.error || "Failed to update profile")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to update profile:", error)
|
||||
toast.error(error.response?.data?.error || "Failed to update profile")
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/png', 'image/jpeg']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error('Only PNG and JPG formats are allowed')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploadingImage(true)
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
||||
if (!token) {
|
||||
toast.error("Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await api.post(
|
||||
"/api/auth/upload-image",
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "multipart/form-data"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
setProfileData({
|
||||
...profileData,
|
||||
avatar: response.data.image
|
||||
})
|
||||
toast.success("Image uploaded successfully")
|
||||
} else {
|
||||
toast.error(response.data.error || "Failed to upload image")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Image upload error:", error)
|
||||
toast.error(error.response?.data?.error || "Failed to upload image")
|
||||
} finally {
|
||||
setIsUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSocialUpdate = async () => {
|
||||
try {
|
||||
// Here you would call your API to update social links
|
||||
// await updateSocialLinks(socialData)
|
||||
console.log("Social links updated:", socialData)
|
||||
toast.success("Social links updated successfully")
|
||||
} catch (error) {
|
||||
console.error("Failed to update social links:", error)
|
||||
toast.error("Failed to update social links")
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
@@ -67,10 +271,10 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50">
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-blue-50 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||
{/* Professional Header */}
|
||||
<header className="bg-white shadow-lg border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<header className="bg-white dark:bg-gray-950 shadow-lg border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-10">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -81,13 +285,17 @@ export default function DashboardPage() {
|
||||
<h1 className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
|
||||
OpenLearnX
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Learn • Earn • Grow</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Learn • Earn • Grow</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl transition-all duration-200">
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl transition-all duration-200"
|
||||
title="Open profile settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
@@ -103,30 +311,36 @@ export default function DashboardPage() {
|
||||
</header>
|
||||
|
||||
{/* Main Dashboard Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<main className="w-full px-4 sm:px-6 lg:px-10 py-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 rounded-2xl p-8 text-white shadow-xl">
|
||||
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-blue-600 dark:from-indigo-700 dark:via-purple-700 dark:to-blue-700 rounded-2xl p-8 text-white shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-2">
|
||||
Welcome back! 👋
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="text-indigo-100 text-lg">
|
||||
<p className="text-indigo-100 dark:text-indigo-200 text-lg">
|
||||
Ready to continue your learning journey?
|
||||
</p>
|
||||
{authMethod === "metamask" && user ? (
|
||||
<div className="mt-3 flex items-center space-x-2">
|
||||
<Wallet className="w-4 h-4 text-orange-300" />
|
||||
<span className="text-sm text-indigo-100">
|
||||
<span className="text-sm text-indigo-100 dark:text-indigo-200">
|
||||
Connected: {user.wallet_address.slice(0, 6)}...{user.wallet_address.slice(-4)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeClass}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : firebaseUser && (
|
||||
) : (
|
||||
<div className="mt-3 flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-blue-300" />
|
||||
<span className="text-sm text-indigo-100">
|
||||
{firebaseUser.email}
|
||||
<span className="text-sm text-indigo-100 dark:text-indigo-200">
|
||||
{user.email || user.id}
|
||||
</span>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${roleBadgeClass}`}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -142,11 +356,28 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Total XP</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.totalXP.toLocaleString()}</p>
|
||||
{isLoadingStats ? (
|
||||
// Loading skeleton
|
||||
<>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 animate-pulse">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-24 mb-3"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-32"></div>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Total XP</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.totalXP.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl shadow-lg">
|
||||
<Trophy className="w-8 h-8 text-white" />
|
||||
@@ -158,11 +389,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Courses</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.coursesCompleted}</p>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Courses</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.coursesCompleted}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-r from-green-500 to-teal-500 rounded-xl shadow-lg">
|
||||
<BookOpen className="w-8 h-8 text-white" />
|
||||
@@ -174,26 +405,26 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Streak</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.currentStreak} days</p>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Streak</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{stats.currentStreak} days</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-r from-orange-500 to-red-500 rounded-xl shadow-lg">
|
||||
<Target className="w-8 h-8 text-white" />
|
||||
<Flame className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mt-4">
|
||||
<span className="text-sm text-orange-600 font-medium">🔥 Keep it up!</span>
|
||||
<span className="text-sm text-orange-600 font-medium">Keep your streak going</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6 hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-600 uppercase tracking-wide">Global Rank</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">#{stats.rank}</p>
|
||||
<p className="text-sm font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">Global Rank</p>
|
||||
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">#{stats.rank}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl shadow-lg">
|
||||
<BarChart3 className="w-8 h-8 text-white" />
|
||||
@@ -204,94 +435,356 @@ export default function DashboardPage() {
|
||||
<span className="text-sm text-purple-600 font-medium">Top 5% learner</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Profile Card with Edit Functionality */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900">Profile</h3>
|
||||
<div id="profile-card" className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
{/* Profile Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setIsEditingProfile(!isEditingProfile)}
|
||||
className="p-2 text-gray-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all duration-200"
|
||||
onClick={() => { setIsEditingProfile(false); setIsEditingSocial(false); }}
|
||||
className={`flex-1 py-3 px-4 text-sm font-semibold transition-all ${
|
||||
!isEditingSocial ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 bg-indigo-50 dark:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{isEditingProfile ? <X className="w-5 h-5" /> : <Edit3 className="w-5 h-5" />}
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditingSocial(true)}
|
||||
className={`flex-1 py-3 px-4 text-sm font-semibold transition-all ${
|
||||
isEditingSocial ? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 bg-indigo-50 dark:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Social Links
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-20 h-20 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<User className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
{isEditingProfile ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.name}
|
||||
onChange={(e) => setProfileData({...profileData, name: e.target.value})}
|
||||
placeholder="Your name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center"
|
||||
/>
|
||||
<textarea
|
||||
value={profileData.bio}
|
||||
onChange={(e) => setProfileData({...profileData, bio: e.target.value})}
|
||||
placeholder="Your bio"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center h-20 resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleProfileUpdate}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors mx-auto"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{!isEditingSocial ? (
|
||||
/* Profile Tab */
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
{profileData.avatar ? (
|
||||
<img
|
||||
src={profileData.avatar}
|
||||
alt="Avatar"
|
||||
className="w-24 h-24 rounded-full mx-auto mb-4 border-4 border-indigo-100 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||
<User className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingProfile ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.name}
|
||||
onChange={(e) => setProfileData({...profileData, name: e.target.value})}
|
||||
placeholder="Your name"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center"
|
||||
/>
|
||||
|
||||
{/* Image Upload Input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handleImageUpload}
|
||||
disabled={isUploadingImage}
|
||||
className="hidden"
|
||||
id="avatar-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className={`w-full flex items-center justify-center space-x-2 px-3 py-2 border-2 border-dashed border-indigo-300 dark:border-indigo-500 rounded-lg hover:bg-indigo-50 dark:hover:bg-gray-700 cursor-pointer transition-colors ${
|
||||
isUploadingImage ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isUploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-indigo-600" />
|
||||
<span className="text-sm text-indigo-600 dark:text-indigo-400">Uploading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 text-indigo-600" />
|
||||
<span className="text-sm text-indigo-600 dark:text-indigo-400">Upload PNG/JPG</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-1">Max 5MB (PNG or JPG only)</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={profileData.bio}
|
||||
onChange={(e) => setProfileData({...profileData, bio: e.target.value})}
|
||||
placeholder="Your bio"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-center h-20 resize-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleProfileUpdate}
|
||||
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-700 dark:hover:bg-indigo-800 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditingProfile(false)}
|
||||
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{profileData.name || "Your Name"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setIsEditingProfile(true)}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mt-2">
|
||||
{profileData.bio || "Add a bio to tell others about yourself"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-gray-700 dark:to-gray-800 rounded-xl border border-indigo-100 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-3">
|
||||
{authMethod === "metamask" ? (
|
||||
<Wallet className="w-6 h-6 text-orange-600" />
|
||||
) : (
|
||||
<Mail className="w-6 h-6 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">Auth Method</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{authMethod === "metamask" ? "MetaMask Wallet" : "Email Account"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 font-medium">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 dark:bg-gray-700 rounded-xl">
|
||||
<Calendar className="w-6 h-6 text-blue-600 dark:text-blue-400 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-900 dark:text-blue-300">{stats.hoursLearned}</p>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">Hours Learned</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-green-50 dark:bg-gray-700 rounded-xl">
|
||||
<Award className="w-6 h-6 text-green-600 dark:text-green-400 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-green-900 dark:text-green-300">{stats.certificatesEarned}</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400 font-medium">Certificates</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Social Links Tab */
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900">
|
||||
{profileData.name || "Your Name"}
|
||||
</h4>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
{profileData.bio || "Add a bio to tell others about yourself"}
|
||||
</p>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Connect Your Social Accounts</h4>
|
||||
|
||||
{isEditingSocial && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Github className="w-5 h-5 text-gray-800 dark:text-gray-400" />
|
||||
<span>GitHub Profile</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={socialData.github}
|
||||
onChange={(e) => setSocialData({...socialData, github: e.target.value})}
|
||||
placeholder="username or profile URL"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Linkedin className="w-5 h-5 text-blue-600" />
|
||||
<span>LinkedIn Profile</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={socialData.linkedin}
|
||||
onChange={(e) => setSocialData({...socialData, linkedin: e.target.value})}
|
||||
placeholder="username or profile URL"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Twitter className="w-5 h-5 text-blue-400" />
|
||||
<span>Twitter Profile</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={socialData.twitter}
|
||||
onChange={(e) => setSocialData({...socialData, twitter: e.target.value})}
|
||||
placeholder="@username or profile URL"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSocialUpdate()
|
||||
setIsEditingSocial(false)
|
||||
}}
|
||||
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-700 dark:hover:bg-indigo-800 text-white rounded-lg transition-colors mt-6"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save Links</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditingSocial ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{socialData.github && (
|
||||
<a
|
||||
href={socialData.github.startsWith('http') ? socialData.github : `https://github.com/${socialData.github}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
<Github className="w-5 h-5 text-gray-800 dark:text-gray-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.github}</span>
|
||||
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</a>
|
||||
)}
|
||||
{socialData.linkedin && (
|
||||
<a
|
||||
href={socialData.linkedin.startsWith('http') ? socialData.linkedin : `https://linkedin.com/in/${socialData.linkedin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
<Linkedin className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.linkedin}</span>
|
||||
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</a>
|
||||
)}
|
||||
{socialData.twitter && (
|
||||
<a
|
||||
href={socialData.twitter.startsWith('http') ? socialData.twitter : `https://twitter.com/${socialData.twitter}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-all"
|
||||
>
|
||||
<Twitter className="w-5 h-5 text-blue-400" />
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 flex-1 truncate">{socialData.twitter}</span>
|
||||
<Link2 className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{!socialData.github && !socialData.linkedin && !socialData.twitter && (
|
||||
<div className="text-center py-8 px-4 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">No social links added yet</p>
|
||||
<button
|
||||
onClick={() => setIsEditingSocial(true)}
|
||||
className="mt-3 text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-semibold"
|
||||
>
|
||||
Add your first link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditingSocial(true)}
|
||||
className="w-full mt-4 flex items-center justify-center space-x-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
<span>Edit Links</span>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
|
||||
<div className="flex items-center space-x-3">
|
||||
{authMethod === "metamask" ? (
|
||||
<Wallet className="w-6 h-6 text-orange-600" />
|
||||
) : (
|
||||
<Mail className="w-6 h-6 text-blue-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">Auth Method</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{authMethod === "metamask" ? "MetaMask Wallet" : "Email Account"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 font-medium">Connected</span>
|
||||
</div>
|
||||
{/* Streak Calendar */}
|
||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-4">Learning Streak</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-orange-600 dark:text-orange-400">{stats.currentStreak}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">days in a row</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-xl">
|
||||
<Calendar className="w-6 h-6 text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-900">{stats.hoursLearned}</p>
|
||||
<p className="text-xs text-blue-600 font-medium">Hours Learned</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-green-50 rounded-xl">
|
||||
<Award className="w-6 h-6 text-green-600 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-green-900">{stats.certificatesEarned}</p>
|
||||
<p className="text-xs text-green-600 font-medium">Certificates</p>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Best streak</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.bestStreak} days</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub-style contribution graph */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3">Last 12 weeks</p>
|
||||
<div className="grid grid-cols-12 gap-1">
|
||||
{[...Array(84)].map((_, i) => {
|
||||
// Calculate activity based on current streak
|
||||
let activity = 0
|
||||
if (stats.currentStreak > 0) {
|
||||
// Days in current streak show full activity
|
||||
if (i >= 84 - stats.currentStreak) {
|
||||
activity = 0.85 + Math.random() * 0.15
|
||||
} else {
|
||||
// Past days show decreasing activity
|
||||
activity = Math.random() * 0.4
|
||||
}
|
||||
} else {
|
||||
// No streak - show light activity
|
||||
activity = Math.random() * 0.3
|
||||
}
|
||||
|
||||
let bgColor = 'bg-gray-100 dark:bg-gray-700'
|
||||
if (activity > 0.75) bgColor = 'bg-green-600'
|
||||
else if (activity > 0.5) bgColor = 'bg-green-400'
|
||||
else if (activity > 0.25) bgColor = 'bg-green-200'
|
||||
else if (activity > 0) bgColor = 'bg-green-100'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-3 h-3 rounded-sm ${bgColor} cursor-pointer hover:ring-2 hover:ring-offset-1 dark:hover:ring-offset-gray-800 hover:ring-green-600 transition-all`}
|
||||
title={`Week ${Math.floor(i / 7) + 1}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 bg-gray-100 dark:bg-gray-700 rounded-sm"></div>
|
||||
<div className="w-3 h-3 bg-green-100 dark:bg-green-900 rounded-sm"></div>
|
||||
<div className="w-3 h-3 bg-green-400 dark:bg-green-600 rounded-sm"></div>
|
||||
<div className="w-3 h-3 bg-green-600 dark:bg-green-500 rounded-sm"></div>
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,71 +792,37 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-700 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-900">Recent Activity</h3>
|
||||
<button className="text-sm text-indigo-600 hover:text-indigo-800 font-semibold hover:bg-indigo-50 px-3 py-1 rounded-lg transition-all duration-200">
|
||||
View all →
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Recent Activity</h3>
|
||||
<button
|
||||
onClick={() => setShowAllActivities((prev) => !prev)}
|
||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 font-semibold hover:bg-indigo-50 dark:hover:bg-gray-700 px-3 py-1 rounded-lg transition-all duration-200"
|
||||
>
|
||||
{showAllActivities ? "Show less" : "View all →"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
type: "course",
|
||||
title: "Completed React Fundamentals",
|
||||
time: "2 hours ago",
|
||||
icon: BookOpen,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-600"
|
||||
},
|
||||
{
|
||||
type: "quiz",
|
||||
title: "Scored 95% on JavaScript Quiz",
|
||||
time: "1 day ago",
|
||||
icon: Award,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
textColor: "text-blue-600"
|
||||
},
|
||||
{
|
||||
type: "streak",
|
||||
title: "7-day learning streak!",
|
||||
time: "Today",
|
||||
icon: Target,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
textColor: "text-orange-600"
|
||||
},
|
||||
{
|
||||
type: "rank",
|
||||
title: "Moved up 5 positions in leaderboard",
|
||||
time: "2 days ago",
|
||||
icon: TrendingUp,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
textColor: "text-purple-600"
|
||||
},
|
||||
].map((activity, index) => (
|
||||
<div key={index} className="flex items-center space-x-4 p-4 hover:bg-gray-50 rounded-xl transition-all duration-200 border border-gray-100 hover:border-gray-200 hover:shadow-md">
|
||||
<div className={`p-3 rounded-xl ${activity.bgColor} shadow-sm`}>
|
||||
<activity.icon className={`w-5 h-5 ${activity.textColor}`} />
|
||||
{visibleActivities.map((activity) => {
|
||||
const iconConfig = activityIconConfig(activity.type)
|
||||
const Icon = iconConfig.icon
|
||||
return (
|
||||
<div key={activity.id} className="flex items-center space-x-4 p-4 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-xl transition-all duration-200 border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-md">
|
||||
<div className={`p-3 rounded-xl ${iconConfig.bgColor} shadow-sm`}>
|
||||
<Icon className={`w-5 h-5 ${iconConfig.textColor}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{activity.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{activity.time}</p>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white">{activity.title}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 mt-1">{activity.description}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{activity.timestamp_utc || activity.completed_at}</p>
|
||||
</div>
|
||||
<div className="w-2 h-2 bg-gray-300 rounded-full"></div>
|
||||
<div className="w-2 h-2 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
|
||||
<h4 className="text-sm font-semibold text-indigo-900 mb-2">🚀 Keep Learning!</h4>
|
||||
<p className="text-xs text-indigo-700">
|
||||
You're doing great! Complete 2 more courses this week to maintain your streak.
|
||||
</p>
|
||||
)})}
|
||||
{realActivities.length === 0 && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">No recent activity yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+40
-13
@@ -45,46 +45,73 @@
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--background: 223 49% 18%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 218 36% 22%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 220 35% 20%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary: 220 32% 28%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted: 220 28% 24%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent: 220 32% 30%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--border: 220 30% 34%;
|
||||
--input: 220 30% 34%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-background: 224 42% 16%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent: 222 33% 24%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-border: 220 30% 34%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.dark .dark\:bg-gray-950,
|
||||
.dark .dark\:bg-gray-900,
|
||||
.dark .dark\:bg-gray-800,
|
||||
.dark .bg-gray-900,
|
||||
.dark .bg-gray-800,
|
||||
.dark .bg-black,
|
||||
.dark .bg-black\/70,
|
||||
.dark .bg-black\/60,
|
||||
.dark .bg-black\/50,
|
||||
.dark .bg-black\/30 {
|
||||
background-color: #22314a !important;
|
||||
}
|
||||
|
||||
.dark .dark\:border-gray-800,
|
||||
.dark .dark\:border-gray-700,
|
||||
.dark .border-gray-700,
|
||||
.dark .border-gray-600 {
|
||||
border-color: rgba(96, 165, 250, 0.24) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-size: clamp(15px, 0.92rem + 0.12vw, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Toaster } from "react-hot-toast"
|
||||
import { AuthProvider } from "@/context/auth-context"
|
||||
import { Navbar } from "@/components/ui/navbar"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { AccountStatusGuard } from "@/components/account-status-guard"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] })
|
||||
|
||||
@@ -26,8 +27,9 @@ export default function RootLayout({
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900">
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Navbar />
|
||||
<AccountStatusGuard />
|
||||
<main className="transition-all duration-300">{children}</main>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-sky-100 dark:from-slate-900 dark:via-blue-950 dark:to-slate-900 p-6">
|
||||
<section className="w-full max-w-xl rounded-2xl border border-blue-200/70 dark:border-blue-900 bg-white/90 dark:bg-slate-900/90 p-8 shadow-xl">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] text-blue-600 dark:text-blue-300">ERROR 404</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">Page not found</h1>
|
||||
<p className="mt-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
The page you requested does not exist or may have been moved.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-lg border border-blue-300 px-4 py-2 text-sm font-semibold text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:text-blue-300 dark:hover:bg-blue-950/50"
|
||||
>
|
||||
Open Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -311,18 +311,18 @@ export default function QuizHostPanel() {
|
||||
|
||||
if (!currentRoom) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="text-center mb-8">
|
||||
<Crown className="h-16 w-16 text-yellow-400 mx-auto mb-4" />
|
||||
<h1 className="text-4xl font-bold mb-4">👑 Quiz Host Panel</h1>
|
||||
<p className="text-gray-400">
|
||||
<Crown className="h-16 w-16 text-yellow-500 mx-auto mb-4" />
|
||||
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">👑 Quiz Host Panel</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Create and manage adaptive quizzes with AI-powered questions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 className="text-xl font-bold mb-4">Create New Quiz Room</h2>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create New Quiz Room</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
@@ -330,7 +330,7 @@ export default function QuizHostPanel() {
|
||||
placeholder="Your name (Host)"
|
||||
value={roomForm.host_name}
|
||||
onChange={(e) => setRoomForm(prev => ({...prev, host_name: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
<input
|
||||
@@ -338,17 +338,17 @@ export default function QuizHostPanel() {
|
||||
placeholder="Quiz room title"
|
||||
value={roomForm.room_title}
|
||||
onChange={(e) => setRoomForm(prev => ({...prev, room_title: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<label className="flex items-center space-x-2 text-gray-900 dark:text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roomForm.is_private}
|
||||
onChange={(e) => setRoomForm(prev => ({...prev, is_private: e.target.checked}))}
|
||||
className="rounded"
|
||||
className="rounded accent-blue-600 dark:accent-blue-500"
|
||||
/>
|
||||
<span>Private Room (requires code)</span>
|
||||
</label>
|
||||
@@ -359,7 +359,7 @@ export default function QuizHostPanel() {
|
||||
placeholder="Max participants"
|
||||
value={roomForm.max_participants}
|
||||
onChange={(e) => setRoomForm(prev => ({...prev, max_participants: parseInt(e.target.value) || 50}))}
|
||||
className="p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
@@ -369,7 +369,7 @@ export default function QuizHostPanel() {
|
||||
placeholder="Duration (minutes)"
|
||||
value={roomForm.duration_minutes}
|
||||
onChange={(e) => setRoomForm(prev => ({...prev, duration_minutes: parseInt(e.target.value) || 30}))}
|
||||
className="p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
min="5"
|
||||
max="180"
|
||||
/>
|
||||
@@ -378,7 +378,7 @@ export default function QuizHostPanel() {
|
||||
<button
|
||||
onClick={createRoom}
|
||||
disabled={!roomForm.host_name || !roomForm.room_title}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 disabled:from-gray-600 disabled:to-gray-600 p-4 rounded-lg font-semibold"
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 dark:from-purple-700 dark:to-blue-700 dark:hover:from-purple-800 dark:hover:to-blue-800 disabled:from-gray-400 disabled:to-gray-400 dark:disabled:from-gray-600 dark:disabled:to-gray-600 p-4 rounded-lg font-semibold text-white"
|
||||
>
|
||||
🚀 Create Quiz Room
|
||||
</button>
|
||||
@@ -390,7 +390,7 @@ export default function QuizHostPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="bg-gray-800 p-4 rounded-lg mb-6">
|
||||
|
||||
@@ -49,12 +49,23 @@ export default function QuizJoinPage() {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token")
|
||||
const storedUserRaw = localStorage.getItem("openlearnx_user")
|
||||
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch('http://127.0.0.1:5000/api/quizzes/join-room', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
room_code: code,
|
||||
username: username.trim()
|
||||
username: username.trim(),
|
||||
wallet_address: storedUser?.wallet_address,
|
||||
user_id: storedUser?.id
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,25 +94,25 @@ export default function QuizJoinPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="text-center mb-8">
|
||||
<Users className="h-16 w-16 text-blue-400 mx-auto mb-4" />
|
||||
<h1 className="text-4xl font-bold mb-4">🎯 Join Quiz</h1>
|
||||
<p className="text-gray-400">
|
||||
<Users className="h-16 w-16 text-blue-600 dark:text-blue-400 mx-auto mb-4" />
|
||||
<h1 className="text-4xl font-bold mb-4 text-gray-900 dark:text-white">🎯 Join Quiz</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Join an adaptive quiz and test your knowledge!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username Input */}
|
||||
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">👤 Enter Your Name</h2>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">👤 Enter Your Name</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
@@ -110,10 +121,10 @@ export default function QuizJoinPage() {
|
||||
<div className="flex space-x-1 mb-6">
|
||||
<button
|
||||
onClick={() => setJoinMode('public')}
|
||||
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 ${
|
||||
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 transition-colors ${
|
||||
joinMode === 'public'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
? 'bg-blue-600 dark:bg-blue-700 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-400 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-5 w-5" />
|
||||
@@ -121,10 +132,10 @@ export default function QuizJoinPage() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setJoinMode('code')}
|
||||
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 ${
|
||||
className={`flex-1 p-4 rounded-lg flex items-center justify-center space-x-2 transition-colors ${
|
||||
joinMode === 'code'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
? 'bg-blue-600 dark:bg-blue-700 text-white'
|
||||
: 'bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-400 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Lock className="h-5 w-5" />
|
||||
@@ -134,9 +145,9 @@ export default function QuizJoinPage() {
|
||||
|
||||
{/* Join with Code */}
|
||||
{joinMode === 'code' && (
|
||||
<div className="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center space-x-2">
|
||||
<Lock className="h-5 w-5 text-yellow-400" />
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700 shadow">
|
||||
<h2 className="text-xl font-bold mb-4 flex items-center space-x-2 text-gray-900 dark:text-white">
|
||||
<Lock className="h-5 w-5 text-yellow-500" />
|
||||
<span>🔐 Join with Room Code</span>
|
||||
</h2>
|
||||
|
||||
@@ -146,7 +157,7 @@ export default function QuizJoinPage() {
|
||||
placeholder="Enter room code (e.g., ABC123)"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
|
||||
className="flex-1 p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="flex-1 p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -60,7 +60,12 @@ export default function QuizPlayPage() {
|
||||
const fetchNextQuestion = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/next-question`)
|
||||
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
||||
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/next-question`, {
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
console.log('Next question response:', data) // ✅ Debug log
|
||||
@@ -98,12 +103,20 @@ export default function QuizPlayPage() {
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
||||
const storedUserRaw = localStorage.getItem("openlearnx_user")
|
||||
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
|
||||
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/session/${sessionId}/submit-answer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
answer: selectedAnswer,
|
||||
question_data: currentQuestion
|
||||
question_data: currentQuestion,
|
||||
user_id: storedUser?.id,
|
||||
wallet_address: storedUser?.wallet_address
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -96,12 +96,21 @@ export default function QuizTaking() {
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const token = localStorage.getItem("openlearnx_jwt_token") || localStorage.getItem("openlearnx_token")
|
||||
const storedUserRaw = localStorage.getItem("openlearnx_user")
|
||||
const storedUser = storedUserRaw ? JSON.parse(storedUserRaw) : null
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:5000/api/quizzes/${quizId}/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
answers,
|
||||
participant_name: 'User' // You can get this from auth context
|
||||
participant_name: storedUser?.name || storedUser?.username || 'User',
|
||||
user_id: storedUser?.id,
|
||||
wallet_address: storedUser?.wallet_address
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,7 +129,7 @@ export default function QuizTaking() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto mb-4"></div>
|
||||
<p>Loading AI Quiz...</p>
|
||||
@@ -131,7 +140,7 @@ export default function QuizTaking() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<p className="text-xl mb-4">{error}</p>
|
||||
@@ -148,7 +157,7 @@ export default function QuizTaking() {
|
||||
|
||||
if (results) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-4">
|
||||
@@ -170,9 +179,9 @@ export default function QuizTaking() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{results.ai_feedback.map((feedback: any, index: number) => (
|
||||
<div key={index} className="bg-gray-900 p-4 rounded border-l-4 border-purple-500">
|
||||
<h3 className="font-semibold mb-2">Question {index + 1}</h3>
|
||||
<p className="text-sm text-gray-300 mb-2">{feedback.question}</p>
|
||||
<div key={index} className="bg-gray-50 dark:bg-gray-900 p-4 rounded border-l-4 border-purple-500">
|
||||
<h3 className="font-semibold mb-2 text-gray-900 dark:text-white">Question {index + 1}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">{feedback.question}</p>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
{feedback.is_correct ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
@@ -213,7 +222,7 @@ export default function QuizTaking() {
|
||||
const progress = ((currentQuestion + 1) / quiz.questions.length) * 100
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -87,22 +87,22 @@ export default function CreateQuizPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:bg-gradient-to-br dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 text-gray-900 dark:text-white">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center space-x-4 mb-8">
|
||||
<button
|
||||
onClick={() => router.push('/quizzes')}
|
||||
className="bg-gray-700 hover:bg-gray-600 p-2 rounded"
|
||||
className="bg-gray-300 dark:bg-gray-700 hover:bg-gray-400 dark:hover:bg-gray-600 p-2 rounded text-gray-900 dark:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold">📝 Create New Quiz</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">📝 Create New Quiz</h1>
|
||||
</div>
|
||||
|
||||
{/* Quiz Details */}
|
||||
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Quiz Information</h2>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Quiz Information</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
@@ -110,21 +110,21 @@ export default function CreateQuizPage() {
|
||||
placeholder="Quiz title"
|
||||
value={quiz.title}
|
||||
onChange={(e) => setQuiz(prev => ({...prev, title: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
placeholder="Quiz description"
|
||||
value={quiz.description}
|
||||
onChange={(e) => setQuiz(prev => ({...prev, description: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={quiz.difficulty}
|
||||
onChange={(e) => setQuiz(prev => ({...prev, difficulty: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="easy">🟢 Easy</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
@@ -134,20 +134,20 @@ export default function CreateQuizPage() {
|
||||
</div>
|
||||
|
||||
{/* Add Question */}
|
||||
<div className="bg-gray-800 p-6 rounded-lg mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Add Question</h2>
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg mb-6 border border-gray-200 dark:border-gray-700 shadow">
|
||||
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">Add Question</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
placeholder="Question text"
|
||||
value={currentQuestion.question_text}
|
||||
onChange={(e) => setCurrentQuestion(prev => ({...prev, question_text: e.target.value}))}
|
||||
className="w-full p-3 bg-gray-700 rounded border border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
className="w-full p-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Options:</label>
|
||||
<label className="text-sm font-medium text-gray-900 dark:text-white">Options:</label>
|
||||
{currentQuestion.options.map((option, index) => (
|
||||
<input
|
||||
key={index}
|
||||
|
||||
@@ -82,43 +82,43 @@ export default function QuizzesPage() {
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy': return 'text-green-400 bg-green-900'
|
||||
case 'medium': return 'text-yellow-400 bg-yellow-900'
|
||||
case 'hard': return 'text-red-400 bg-red-900'
|
||||
default: return 'text-gray-400 bg-gray-700'
|
||||
case 'easy': return 'text-emerald-800 bg-emerald-100 dark:text-emerald-200 dark:bg-emerald-700/60'
|
||||
case 'medium': return 'text-amber-800 bg-amber-100 dark:text-amber-200 dark:bg-amber-700/60'
|
||||
case 'hard': return 'text-rose-800 bg-rose-100 dark:text-rose-200 dark:bg-rose-700/60'
|
||||
default: return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'waiting': return 'text-yellow-400 bg-yellow-900'
|
||||
case 'active': return 'text-green-400 bg-green-900'
|
||||
case 'completed': return 'text-gray-400 bg-gray-700'
|
||||
default: return 'text-gray-400 bg-gray-700'
|
||||
case 'waiting': return 'text-amber-800 bg-amber-100 dark:text-amber-200 dark:bg-amber-700/60'
|
||||
case 'active': return 'text-emerald-800 bg-emerald-100 dark:text-emerald-200 dark:bg-emerald-700/60'
|
||||
case 'completed': return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
|
||||
default: return 'text-slate-700 bg-slate-100 dark:text-slate-200 dark:bg-slate-600/60'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && activeTab === 'traditional' && quizzes.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#dbe8ff] via-[#cfdfff] to-[#d8ccff] dark:from-[#1f3f8a] dark:via-[#2b3f95] dark:to-[#4e2c97] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto mb-4"></div>
|
||||
<p>Loading quizzes...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 dark:border-blue-200 mx-auto mb-4"></div>
|
||||
<p className="text-slate-700 dark:text-blue-100">Loading quizzes...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 text-white">
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#dbe8ff] via-[#cfdfff] to-[#d8ccff] dark:from-[#1f3f8a] dark:via-[#2b3f95] dark:to-[#4e2c97] text-slate-900 dark:text-white">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3">
|
||||
<h1 className="text-4xl font-bold mb-4 flex items-center justify-center space-x-3 text-slate-900 dark:text-white">
|
||||
<Trophy className="h-10 w-10 text-yellow-400" />
|
||||
<span>🧠 OpenLearnX Quiz Platform</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
<p className="text-slate-600 dark:text-blue-100/90 max-w-2xl mx-auto text-base">
|
||||
Experience adaptive quizzes with AI-powered questions and real-time difficulty adjustment
|
||||
</p>
|
||||
</div>
|
||||
@@ -135,14 +135,14 @@ export default function QuizzesPage() {
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`px-6 py-3 rounded-lg flex items-center space-x-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-900/40'
|
||||
: 'bg-white/70 text-slate-700 hover:bg-white dark:bg-slate-700/60 dark:text-blue-100 dark:hover:bg-slate-600/70'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-5 w-5" />
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">{tab.label}</div>
|
||||
<div className="text-xs opacity-75">{tab.description}</div>
|
||||
<div className="font-semibold text-slate-900 dark:text-white">{tab.label}</div>
|
||||
<div className="text-xs opacity-80 text-slate-500 dark:text-blue-100">{tab.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -155,7 +155,7 @@ export default function QuizzesPage() {
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8 justify-center items-center">
|
||||
<button
|
||||
onClick={() => router.push('/quiz-host')}
|
||||
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
|
||||
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>👑 Host a Quiz</span>
|
||||
@@ -163,7 +163,7 @@ export default function QuizzesPage() {
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/quiz-join')}
|
||||
className="bg-green-600 hover:bg-green-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
|
||||
className="bg-emerald-500 hover:bg-emerald-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
<span>🎯 Join Quiz</span>
|
||||
@@ -179,7 +179,7 @@ export default function QuizzesPage() {
|
||||
</h2>
|
||||
<button
|
||||
onClick={fetchPublicRooms}
|
||||
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded flex items-center space-x-2"
|
||||
className="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded flex items-center space-x-2"
|
||||
>
|
||||
<span>🔄 Refresh</span>
|
||||
</button>
|
||||
@@ -187,19 +187,19 @@ export default function QuizzesPage() {
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p>Loading rooms...</p>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 dark:border-blue-200 mx-auto mb-4"></div>
|
||||
<p className="text-slate-700 dark:text-blue-100">Loading rooms...</p>
|
||||
</div>
|
||||
) : publicRooms.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-800 rounded-lg">
|
||||
<Globe className="h-16 w-16 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">No Public Rooms Available</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
<div className="text-center py-12 bg-white/75 dark:bg-[#22314a] rounded-lg border border-blue-200 dark:border-blue-400/20">
|
||||
<Globe className="h-16 w-16 text-blue-500/60 dark:text-blue-200/60 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2 text-slate-900 dark:text-white">No Public Rooms Available</h3>
|
||||
<p className="text-slate-600 dark:text-blue-100/85 mb-6">
|
||||
Be the first to create a public quiz room!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/quiz-host')}
|
||||
className="bg-purple-600 hover:bg-purple-700 px-6 py-3 rounded-lg font-semibold"
|
||||
className="bg-violet-500 hover:bg-violet-600 px-6 py-3 rounded-lg font-semibold"
|
||||
>
|
||||
🚀 Create Room
|
||||
</button>
|
||||
@@ -209,7 +209,7 @@ export default function QuizzesPage() {
|
||||
{publicRooms.map((room) => (
|
||||
<div
|
||||
key={room.room_id}
|
||||
className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-colors border border-gray-700"
|
||||
className="bg-white/75 dark:bg-[#22314a] rounded-lg p-6 hover:bg-white dark:hover:bg-[#2a3d59] transition-colors border border-blue-200 dark:border-blue-400/20"
|
||||
>
|
||||
{/* Room Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
@@ -218,7 +218,7 @@ export default function QuizzesPage() {
|
||||
<Globe className="h-5 w-5 text-green-400" />
|
||||
<span>{room.title}</span>
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">Host: {room.host_name}</p>
|
||||
<p className="text-slate-600 dark:text-blue-100/80 text-sm">Host: {room.host_name}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(room.status)}`}>
|
||||
{room.status}
|
||||
@@ -227,13 +227,13 @@ export default function QuizzesPage() {
|
||||
|
||||
{/* Room Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 text-sm">
|
||||
<div className="bg-gray-700 p-3 rounded text-center">
|
||||
<div className="bg-blue-50 dark:bg-[#1a2740] p-3 rounded text-center">
|
||||
<div className="font-bold text-blue-400">{room.participants_count}</div>
|
||||
<div className="text-gray-400">Participants</div>
|
||||
<div className="text-slate-600 dark:text-blue-100/70">Participants</div>
|
||||
</div>
|
||||
<div className="bg-gray-700 p-3 rounded text-center">
|
||||
<div className="bg-blue-50 dark:bg-[#1a2740] p-3 rounded text-center">
|
||||
<div className="font-bold text-purple-400">{room.questions_count}</div>
|
||||
<div className="text-gray-400">Questions</div>
|
||||
<div className="text-slate-600 dark:text-blue-100/70">Questions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function QuizzesPage() {
|
||||
|
||||
{/* Room Code */}
|
||||
<div className="text-center mb-4">
|
||||
<span className="bg-gray-700 px-3 py-1 rounded font-mono text-blue-400">
|
||||
<span className="bg-blue-50 dark:bg-[#1a2740] px-3 py-1 rounded font-mono text-blue-500 dark:text-blue-300">
|
||||
Code: {room.room_code}
|
||||
</span>
|
||||
</div>
|
||||
@@ -254,7 +254,7 @@ export default function QuizzesPage() {
|
||||
{/* Join Button */}
|
||||
<button
|
||||
onClick={() => router.push(`/quiz-join?room=${room.room_code}`)}
|
||||
className="w-full bg-green-600 hover:bg-green-700 p-3 rounded font-semibold flex items-center justify-center space-x-2"
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-600 p-3 rounded font-semibold flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span>Join Room</span>
|
||||
@@ -273,31 +273,31 @@ export default function QuizzesPage() {
|
||||
<div className="max-w-2xl mx-auto mb-8">
|
||||
<Brain className="h-16 w-16 text-purple-400 mx-auto mb-4" />
|
||||
<h2 className="text-3xl font-bold mb-4">🧠 Adaptive AI Quiz</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
<p className="text-slate-600 dark:text-blue-100/85 mb-6">
|
||||
Experience an intelligent quiz that adapts to your skill level in real-time using our trained CNN model.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-gray-800 p-4 rounded-lg">
|
||||
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
|
||||
<Target className="h-8 w-8 text-blue-400 mx-auto mb-2" />
|
||||
<h3 className="font-semibold mb-1">Adaptive Difficulty</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-slate-600 dark:text-blue-100/80">
|
||||
Questions adjust based on your performance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-4 rounded-lg">
|
||||
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
|
||||
<Brain className="h-8 w-8 text-purple-400 mx-auto mb-2" />
|
||||
<h3 className="font-semibold mb-1">AI Predictions</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-slate-600 dark:text-blue-100/80">
|
||||
See how our AI model would answer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-4 rounded-lg">
|
||||
<div className="bg-white/75 dark:bg-[#22314a] p-4 rounded-lg border border-blue-200 dark:border-blue-400/20">
|
||||
<Sparkles className="h-8 w-8 text-green-400 mx-auto mb-2" />
|
||||
<h3 className="font-semibold mb-1">Smart Analytics</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-slate-600 dark:text-blue-100/80">
|
||||
Track performance across difficulty levels
|
||||
</p>
|
||||
</div>
|
||||
@@ -305,7 +305,7 @@ export default function QuizzesPage() {
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/adaptive-quiz')}
|
||||
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-8 py-4 rounded-lg font-semibold flex items-center justify-center space-x-2 mx-auto"
|
||||
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-8 py-4 rounded-lg font-semibold flex items-center justify-center space-x-2 mx-auto"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span>🚀 Start Adaptive Quiz</span>
|
||||
@@ -322,7 +322,7 @@ export default function QuizzesPage() {
|
||||
{aiAvailable && (
|
||||
<button
|
||||
onClick={() => router.push('/quizzes/generate')}
|
||||
className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
|
||||
className="bg-gradient-to-r from-violet-500 to-blue-500 hover:from-violet-600 hover:to-blue-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
<Brain className="h-5 w-5" />
|
||||
<Sparkles className="h-4 w-4" />
|
||||
@@ -332,7 +332,7 @@ export default function QuizzesPage() {
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/quizzes/create')}
|
||||
className="bg-green-600 hover:bg-green-700 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
|
||||
className="bg-emerald-500 hover:bg-emerald-600 px-6 py-3 rounded-lg font-semibold flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span>Create Manual Quiz</span>
|
||||
@@ -341,12 +341,12 @@ export default function QuizzesPage() {
|
||||
|
||||
{/* AI Status Banner */}
|
||||
{aiAvailable && (
|
||||
<div className="bg-gradient-to-r from-purple-900 to-blue-900 border border-purple-600 p-4 rounded-lg mb-8">
|
||||
<div className="bg-white/75 dark:bg-[#22314a] border border-blue-200 dark:border-blue-400/20 p-4 rounded-lg mb-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Brain className="h-6 w-6 text-purple-400" />
|
||||
<Brain className="h-6 w-6 text-purple-300" />
|
||||
<div>
|
||||
<h3 className="font-semibold">🤖 AI Service Active</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white">🤖 AI Service Active</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-blue-100/80">
|
||||
Our trained CNN model is ready to generate intelligent quizzes and provide feedback
|
||||
</p>
|
||||
</div>
|
||||
@@ -357,15 +357,15 @@ export default function QuizzesPage() {
|
||||
{/* Traditional Quizzes Grid */}
|
||||
{quizzes.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Brain className="h-16 w-16 text-gray-600 mx-auto mb-4" />
|
||||
<Brain className="h-16 w-16 text-blue-500/60 dark:text-blue-200/60 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">No Traditional Quizzes Yet</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
<p className="text-slate-600 dark:text-blue-100/80 mb-6">
|
||||
Create your first quiz or generate one using AI
|
||||
</p>
|
||||
{aiAvailable && (
|
||||
<button
|
||||
onClick={() => router.push('/quizzes/generate')}
|
||||
className="bg-purple-600 hover:bg-purple-700 px-6 py-3 rounded-lg font-semibold"
|
||||
className="bg-violet-500 hover:bg-violet-600 px-6 py-3 rounded-lg font-semibold"
|
||||
>
|
||||
🚀 Generate AI Quiz
|
||||
</button>
|
||||
@@ -376,7 +376,7 @@ export default function QuizzesPage() {
|
||||
{quizzes.map((quiz) => (
|
||||
<div
|
||||
key={quiz._id}
|
||||
className="bg-gray-800 rounded-lg p-6 hover:bg-gray-750 transition-colors cursor-pointer"
|
||||
className="bg-white/75 dark:bg-[#22314a] rounded-lg p-6 hover:bg-white dark:hover:bg-[#2a3d59] transition-colors cursor-pointer border border-blue-200 dark:border-blue-400/20"
|
||||
onClick={() => router.push(`/quizzes/${quiz.id}`)}
|
||||
>
|
||||
{/* Quiz Header */}
|
||||
@@ -393,12 +393,12 @@ export default function QuizzesPage() {
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-400 text-sm mb-4 line-clamp-2">
|
||||
<p className="text-slate-600 dark:text-gray-300 text-sm mb-4 line-clamp-2">
|
||||
{quiz.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600 dark:text-blue-100/70">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Users className="h-4 w-4" />
|
||||
@@ -411,7 +411,7 @@ export default function QuizzesPage() {
|
||||
</div>
|
||||
|
||||
{quiz.generated_by === 'AI' && (
|
||||
<div className="flex items-center space-x-1 text-purple-400">
|
||||
<div className="flex items-center space-x-1 text-purple-300">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span className="text-xs">AI Generated</span>
|
||||
</div>
|
||||
@@ -419,8 +419,8 @@ export default function QuizzesPage() {
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-700">
|
||||
<span className="text-xs text-gray-500">
|
||||
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-400/20">
|
||||
<span className="text-xs text-slate-500 dark:text-blue-100/70">
|
||||
Created {new Date(quiz.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user