Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/chathistory/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getAuthUser } from "@/lib/auth";
import ChatHistoryPage from "@/components/chatbot/ChatHistory";

export default async function DashboardPage() {
const user = await getAuthUser();
if (!user || !user.hasCompletedOnboarding) {
return null;
}

return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-4 text-purple-600">Chat History</h1>
<ChatHistoryPage />
</div>
);
}
7 changes: 4 additions & 3 deletions app/courses/[courseId]/modules/[moduleId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ModuleDetail from "@/components/organisation/courses/ModuleDetail";
import ModuleChatBot from "@/components/chatbot/ModuleChatBot";
import { getAuthUser } from "@/lib/auth";

export default async function ModulePage({
Expand All @@ -8,11 +9,11 @@ export default async function ModulePage({
}) {
const user = await getAuthUser();
const isAdmin = user?.organisation?.role === "admin";
const { moduleId } = await params;
const { courseId, moduleId } = await params;

return (
<div className="max-w-3xl mx-auto bg-white p-6 rounded shadow">
<ModuleDetail moduleId={moduleId} isAdmin={isAdmin} />
<div className="max-w-3xl mx-auto bg-white p-6 rounded">
<ModuleDetail courseId={courseId} moduleId={moduleId} isAdmin={isAdmin} />
</div>
);
}
1 change: 1 addition & 0 deletions components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const menuSections = [
{
heading: "Management",
items: [
{ label: "Chat History", href: "/chathistory", icon: ClockIcon },
{ label: "History", href: "/history", icon: ClockIcon },
{ label: "Settings", href: "/settings", icon: CogIcon },
],
Expand Down
161 changes: 161 additions & 0 deletions components/chatbot/ChatHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";

type ChatLog = {
id: number;
course_id: number;
module_id: number;
name: string;
title: string;
question: string;
answer: string;
created_at: string;
};

function formatDate(dateString: string) {
const date = new Date(dateString);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}

function groupLogsByModule(logs: ChatLog[]) {
const grouped: Record<
string,
{
course_id: number;
module_id: number;
course: string;
module: string;
logs: ChatLog[];
}
> = {};
for (const log of logs) {
const key = `${log.course_id}:${log.module_id}`;
if (!grouped[key]) {
grouped[key] = {
course_id: log.course_id,
module_id: log.module_id,
course: log.name,
module: log.title,
logs: [],
};
}
grouped[key].logs.push(log);
}
return Object.values(grouped);
}

export default function ChatHistoryPage() {
const [logs, setLogs] = useState<ChatLog[]>([]);
const [openPanels, setOpenPanels] = useState<Record<string, boolean>>({});

useEffect(() => {
const fetchLogs = async () => {
try {
const res = await fetch("/api/chatbot/history", {
credentials: "include",
});
const data = await res.json();
if (data.success && Array.isArray(data.logs)) {
setLogs(data.logs);
if (data.logs.length > 0) {
const mostRecent = data.logs[0];
setOpenPanels({
[`${mostRecent.course_id}:${mostRecent.module_id}`]: true,
});
}
}
} catch (err) {
// Ignore errors in loading logs
}
};
fetchLogs();
}, []);

const grouped = groupLogsByModule(logs);

return (
<div className="bg-gray-50 border rounded-xl p-5 shadow">
<h3 className="font-bold text-lg mb-4">Your Chatbot Q&A History</h3>
{logs.length === 0 ? (
<div className="text-gray-400 text-sm">No questions asked yet!</div>
) : (
<div className="flex flex-col gap-4 max-h-[600px] overflow-y-auto">
{grouped.map((mod) => {
const panelKey = `${mod.course_id}:${mod.module_id}`;
const isOpen = openPanels[panelKey] || false;
return (
<div key={panelKey} className="border rounded bg-white">
{/* Panel header */}
<button
className={`w-full text-left flex items-center justify-between px-4 py-3 cursor-pointer select-none ${
isOpen ? "bg-purple-50" : "bg-gray-100"
}`}
onClick={() =>
setOpenPanels((prev) => ({
...prev,
[panelKey]: !prev[panelKey],
}))
}
>
<span className="flex flex-col gap-1">
<span>
<Link
href={`/courses/${mod.course_id}`}
className="font-semibold text-purple-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{mod.course}
</Link>
<span className="text-gray-400 px-1">/</span>
<Link
href={`/courses/${mod.course_id}/modules/${mod.module_id}`}
className="italic text-blue-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{mod.module}
</Link>
</span>
<span className="text-xs text-gray-500">
{mod.logs.length}{" "}
{mod.logs.length === 1 ? "question" : "questions"}
</span>
</span>
<span className="text-xs text-gray-600">
{isOpen ? "▼" : "▶"}
</span>
</button>

{isOpen && (
<div className="divide-y">
{mod.logs.map((log, i) => (
<div key={log.id} className="p-4 space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>{formatDate(log.created_at)}</span>
</div>
<div className="mb-1 text-right">
<span className="inline-block px-3 py-2 rounded-lg bg-blue-100 text-blue-900">
{log.question}
</span>
</div>
<div className="text-left">
<span className="inline-block px-3 py-2 rounded-lg bg-green-50 text-green-900">
{log.answer}
</span>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
138 changes: 138 additions & 0 deletions components/chatbot/ModuleChatBot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"use client";

import { useEffect, useState } from "react";

interface ModuleChatbotProps {
courseId: string;
moduleId: string;
isEnrolled: boolean;
}

type ChatMessage = { type: "user" | "assistant"; content: string };

export default function ModuleChatbot({
courseId,
moduleId,
isEnrolled,
}: ModuleChatbotProps) {
const [question, setQuestion] = useState("");
const [chat, setChat] = useState<
{ type: "user" | "assistant"; content: string }[]
>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

if (!isEnrolled) {
return null;
}

useEffect(() => {
const fetchLogs = async () => {
try {
const res = await fetch("/api/chatbot/logs", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId, moduleId }),
});
const data = await res.json();
if (data.success && Array.isArray(data.logs)) {
const logMessages: ChatMessage[] = [];
data.logs
.reverse()
.forEach((log: { question: string; answer: string }) => {
logMessages.push({ type: "user", content: log.question });
logMessages.push({ type: "assistant", content: log.answer });
});
setChat(logMessages);
}
} catch (err) {
// Ignore errors in loading logs
}
};
fetchLogs();
}, []);

const sendQuestion = async (e: React.FormEvent) => {
e.preventDefault();
if (!question.trim()) return;
setError(null);
setLoading(true);

setChat((prev) => [...prev, { type: "user", content: question }]);

try {
const res = await fetch("/api/chatbot/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ courseId, moduleId, question }),
});
const data = await res.json();

if (data?.success && data.answer) {
setChat((prev) => [
...prev,
{ type: "assistant", content: data.answer },
]);
} else {
setError(data.message || "Something went wrong.");
}
} catch (err: any) {
setError("Failed to get response. Please try again.");
}
setLoading(false);
setQuestion("");
};

return (
<div className="bg-gray-50 border rounded-xl p-5 shadow">
<h3 className="font-bold text-lg mb-2">Module Assistant</h3>
<div>Note: Bot does not have access to course materials.</div>
<div className="min-h-[120px] max-h-60 overflow-y-auto flex flex-col gap-3 mb-4">
{chat.length === 0 && (
<div className="text-gray-400 text-sm">
Ask a question about this course!
</div>
)}
{chat.map((msg, i) => (
<div
key={i}
className={msg.type === "user" ? "text-right" : "text-left"}
>
<span
className={`inline-block px-3 py-2 rounded-lg ${
msg.type === "user"
? "bg-blue-100 text-blue-900"
: "bg-green-50 text-green-900"
}`}
>
{msg.content}
</span>
</div>
))}
{loading && (
<div className="text-gray-400 text-xs">Assistant is typing…</div>
)}
</div>
{error && <div className="text-red-500 text-xs mb-2">{error}</div>}
<form onSubmit={sendQuestion} className="flex gap-2">
<input
type="text"
className="flex-1 border rounded px-3 py-2 focus:outline-none"
placeholder="Type your question…"
value={question}
onChange={(e) => setQuestion(e.target.value)}
disabled={loading}
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
disabled={loading || !question.trim()}
>
Send
</button>
</form>
</div>
);
}
1 change: 1 addition & 0 deletions components/organisation/OrgNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const menuSections = [
items: [
{ label: "My Organisation", href: "/organisation", icon: UsersIcon },
{ label: "Users", href: "/users", icon: UsersIcon },
{ label: "Chat History", href: "/chathistory", icon: ClockIcon },
{ label: "History", href: "/history", icon: ClockIcon },
{ label: "Settings", href: "/settings", icon: CogIcon },
],
Expand Down
Loading