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
100 changes: 100 additions & 0 deletions src/components/ai/IntelligentProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use client';

import { useState, useEffect } from 'react';
import { TrendingUp } from 'lucide-react';
import { apiClient } from '@/lib/api';

// GET /api/ai/progress → { courses: CourseProgress[]; insights: string[] }

interface CourseProgress {
id: string;
title: string;
percent: number;
}

interface ProgressData {
courses: CourseProgress[];
insights: string[];
}

function ProgressBar({ percent }: { percent: number }) {
const clamped = Math.min(100, Math.max(0, percent));
return (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${clamped}%` }}
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
/>
</div>
);
}

export default function IntelligentProgress() {
const [data, setData] = useState<ProgressData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);

useEffect(() => {
apiClient
.get<ProgressData>('/api/ai/progress')
.then(setData)
.catch(() => setError(true))
.finally(() => setLoading(false));
}, []);

return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<TrendingUp className="w-5 h-5 text-green-500" />
<h2 className="font-semibold text-gray-900 dark:text-white text-sm">Your Progress</h2>
</div>

<div className="p-4 space-y-4">
{loading && (
<div className="animate-pulse space-y-3">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
)}

{error && (
<p className="text-sm text-center text-red-500 py-4">Failed to load progress.</p>
)}

{data && (
<>
<div className="space-y-3">
{data.courses.map((course) => (
<div key={course.id} className="space-y-1">
<div className="flex justify-between text-xs text-gray-700 dark:text-gray-300">
<span className="truncate max-w-[75%]">{course.title}</span>
<span className="font-medium">{course.percent}%</span>
</div>
<ProgressBar percent={course.percent} />
</div>
))}
</div>

{data.insights.length > 0 && (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 space-y-1">
{data.insights.map((insight, i) => (
<p key={i} className="text-xs text-gray-500 dark:text-gray-400">
💡 {insight}
</p>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
155 changes: 155 additions & 0 deletions src/components/ai/LearningAssistant.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
'use client';

import { useState, useRef, useEffect, useCallback } from 'react';
import { Send, Bot, User } from 'lucide-react';
import { apiClient } from '@/lib/api';

// POST /api/ai/chat — { message: string; context?: string } → { reply: string }

interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
}

interface LearningAssistantProps {
context?: string;
}

export default function LearningAssistant({ context }: LearningAssistantProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]);

const send = useCallback(async () => {
const text = input.trim();
if (!text || loading) return;

const userMsg: Message = { id: crypto.randomUUID(), role: 'user', content: text };
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);

try {
const { reply } = await apiClient.post<{ reply: string }>('/api/ai/chat', {
message: text,
context,
});
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: reply },
]);
} catch {
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: 'Sorry, something went wrong.' },
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
}, [input, loading, context]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
};

return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<Bot className="w-5 h-5 text-blue-500" />
<h2 className="font-semibold text-gray-900 dark:text-white text-sm">Learning Assistant</h2>
</div>

{/* Message thread */}
<div
role="log"
aria-live="polite"
aria-label="Conversation"
className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"
>
{messages.length === 0 && (
<p className="text-sm text-center text-gray-400 mt-8">
Ask me anything about your courses!
</p>
)}

{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{msg.role === 'assistant' && (
<Bot className="w-6 h-6 text-blue-500 shrink-0 mt-0.5" />
)}
<div
className={`max-w-[75%] rounded-2xl px-3 py-2 text-sm ${
msg.role === 'user'
? 'bg-blue-600 text-white rounded-br-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white rounded-bl-sm'
}`}
>
{msg.content}
</div>
{msg.role === 'user' && (
<User className="w-6 h-6 text-gray-400 shrink-0 mt-0.5" />
)}
</div>
))}

{/* Typing indicator */}
{loading && (
<div className="flex gap-2 justify-start" aria-label="Assistant is typing">
<Bot className="w-6 h-6 text-blue-500 shrink-0 mt-0.5" />
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-sm px-3 py-2">
<span className="flex gap-1">
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: `${i * 150}ms` }}
/>
))}
</span>
</div>
</div>
)}

<div ref={bottomRef} />
</div>

{/* Input */}
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question…"
aria-label="Message input"
disabled={loading}
className="flex-1 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white disabled:opacity-50"
/>
<button
onClick={send}
disabled={loading || !input.trim()}
aria-label="Send message"
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 disabled:opacity-50 transition-colors"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
);
}
101 changes: 101 additions & 0 deletions src/components/ai/NaturalLanguageQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use client';

import { useState, useCallback } from 'react';
import { Search, ExternalLink } from 'lucide-react';
import { apiClient } from '@/lib/api';

// POST /api/ai/search — { query: string } → { results: SearchResult[] }

interface SearchResult {
id: string;
title: string;
description: string;
url: string;
}

export default function NaturalLanguageQuery() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);

const search = useCallback(async () => {
const q = query.trim();
if (!q || loading) return;
setLoading(true);
setError(false);
try {
const { results: res } = await apiClient.post<{ results: SearchResult[] }>(
'/api/ai/search',
{ query: q },
);
setResults(res);
} catch {
setError(true);
setResults(null);
} finally {
setLoading(false);
}
}, [query, loading]);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') search();
};

return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything, e.g. 'intro to machine learning'…"
aria-label="Natural language search"
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white"
/>
</div>
<button
onClick={search}
disabled={loading || !query.trim()}
aria-label="Search"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-500 disabled:opacity-50 transition-colors"
>
{loading ? '…' : 'Search'}
</button>
</div>
</div>

<div className="p-4 space-y-3">
{error && (
<p className="text-sm text-center text-red-500">Search failed. Please try again.</p>
)}

{results !== null && results.length === 0 && (
<p className="text-sm text-center text-gray-400 py-4">No results found.</p>
)}

{results?.map((item) => (
<div
key={item.id}
className="p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-1"
>
<p className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{item.description}
</p>
<a
href={item.url}
className="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Open <ExternalLink className="w-3 h-3" />
</a>
</div>
))}
</div>
</div>
);
}
Loading
Loading