1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 14:22:41 +02:00

Refactor AI course

This commit is contained in:
Kamran Ahmed
2025-06-09 13:13:23 +01:00
parent 8c4ae121fe
commit ec00ac6990
10 changed files with 123 additions and 65 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1748277554631
"lastUpdateCheck": 1749465237682
}
}

View File

@@ -33,7 +33,7 @@ export function AIChatCourse(props: AIChatCourseProps) {
return null;
}
const courseSearchUrl = `/ai/search?term=${course?.keyword}&difficulty=${course?.difficulty}`;
const courseSearchUrl = `/ai/course?term=${course?.keyword}&difficulty=${course?.difficulty}`;
return (
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">

View File

@@ -0,0 +1,75 @@
import { ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '../../lib/classname';
import type { LucideIcon } from 'lucide-react';
type BaseDropdownProps<T extends string> = {
value: T;
options: readonly T[];
onChange: (value: T) => void;
icons?: Record<T, LucideIcon>;
};
export function BaseDropdown<T extends string>(props: BaseDropdownProps<T>) {
const { value, options, onChange, icons } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const Icon = icons?.[value];
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
)}
>
{Icon && <Icon size={16} />}
<span className="capitalize">{value}</span>
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{options.map((option) => {
const OptionIcon = icons?.[option];
return (
<button
key={option}
type="button"
onClick={() => {
onChange(option);
setIsOpen(false);
}}
className={cn(
'flex items-center gap-2 px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
value === option && 'bg-gray-200 font-medium hover:bg-gray-200',
)}
>
{OptionIcon && <OptionIcon size={16} />}
{option}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,7 @@
import { ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '../../lib/classname';
import { BaseDropdown } from './BaseDropdown';
import {
difficultyLevels,
type DifficultyLevel,
difficultyLevels,
type DifficultyLevel,
} from '../GenerateCourse/AICourse';
type DifficultyDropdownProps = {
@@ -14,56 +12,11 @@ type DifficultyDropdownProps = {
export function DifficultyDropdown(props: DifficultyDropdownProps) {
const { value, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
)}
>
<span className="capitalize">{value}</span>
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{difficultyLevels.map((level) => (
<button
key={level}
type="button"
onClick={() => {
onChange(level);
setIsOpen(false);
}}
className={cn(
'px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
value === level && 'bg-gray-200 font-medium hover:bg-gray-200',
)}
>
{level}
</button>
))}
</div>
)}
</div>
<BaseDropdown
value={value}
options={difficultyLevels}
onChange={onChange}
/>
);
}

View File

@@ -0,0 +1,28 @@
import { BaseDropdown } from './BaseDropdown';
import { BookOpen, FileText } from 'lucide-react';
export const natureTypes = ['course', 'document'] as const;
export type NatureType = (typeof natureTypes)[number];
const natureIcons = {
course: BookOpen,
document: FileText,
} as const;
type NatureDropdownProps = {
value: NatureType;
onChange: (value: NatureType) => void;
};
export function NatureDropdown(props: NatureDropdownProps) {
const { value, onChange } = props;
return (
<BaseDropdown
value={value}
options={natureTypes}
onChange={onChange}
icons={natureIcons}
/>
);
}

View File

@@ -4,6 +4,7 @@ import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { FineTuneCourse } from './FineTuneCourse';
import { DifficultyDropdown } from '../AITutor/DifficultyDropdown';
import { NatureDropdown, type NatureType } from '../AITutor/NatureDropdown';
import {
clearFineTuneData,
getCourseFineTuneData,
@@ -26,6 +27,7 @@ type AICourseProps = {};
export function AICourse(props: AICourseProps) {
const [keyword, setKeyword] = useState('');
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
const [nature, setNature] = useState<NatureType>('course');
const [hasFineTuneData, setHasFineTuneData] = useState(false);
const [about, setAbout] = useState('');
@@ -81,7 +83,7 @@ export function AICourse(props: AICourseProps) {
});
}
window.location.href = `/ai/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}`;
window.location.href = `/ai/course?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
}
return (
@@ -131,6 +133,7 @@ export function AICourse(props: AICourseProps) {
<div className="flex flex-col items-start justify-between gap-2 px-4 pb-4 md:flex-row md:items-center">
<div className="flex flex-row items-center gap-2">
<div className="flex flex-row gap-2">
<NatureDropdown value={nature} onChange={setNature} />
<DifficultyDropdown
value={difficulty}
onChange={setDifficulty}
@@ -148,7 +151,6 @@ export function AICourse(props: AICourseProps) {
id="fine-tune-checkbox"
/>
Explain more
<span className="hidden md:inline"> for a better course</span>
</label>
</div>

View File

@@ -24,7 +24,7 @@ export function CreateCourseModal(props: CreateCourseModalProps) {
const formData = new FormData(e.target as HTMLFormElement);
const subject = formData.get('subject');
window.location.href = `/ai/search?term=${subject}&difficulty=beginner&src=topic`;
window.location.href = `/ai/course?term=${subject}&difficulty=beginner&src=topic`;
onClose();
}}
>

View File

@@ -278,7 +278,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
return;
}
}}
href={`/ai/search?term=${subject}&difficulty=beginner&src=topic`}
href={`/ai/course?term=${subject}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
>
{subject}
@@ -289,7 +289,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
{roadmapTreeMapping?.subjects?.length === 0 && (
<a
target="_blank"
href={`/ai/search?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
href={`/ai/course?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
>
{nodeTextParts.slice(1).map((text, index) => {

View File

@@ -121,7 +121,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
const CREATOR_ID_REGEX = new RegExp('@CREATORID:(\\w+)@');
await readStream(reader, {
onStream: (result) => {
onStream: async (result) => {
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
@@ -166,7 +166,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
console.error('Error parsing streamed course content:', e);
}
},
onStreamEnd: (result) => {
onStreamEnd: async (result) => {
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '')

View File

@@ -9,7 +9,7 @@ import { CheckSubscriptionVerification } from '../../components/Billing/CheckSub
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl='/ai/search'
canonicalUrl='/ai/course'
noIndex={true}
>
<GenerateAICourse client:load />