1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-02 22:02:39 +02:00
This commit is contained in:
Arik Chakma
2025-07-04 20:56:11 +06:00
parent 06aa93a46d
commit 250d5ae87f
5 changed files with 303 additions and 9 deletions

View File

@@ -0,0 +1,86 @@
import { PersonStandingIcon } from 'lucide-react';
import { useState } from 'react';
import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap';
import { renderTopicProgress } from '../../lib/resource-progress';
import { PersonalizedRoadmapModal } from './PersonalizedRoadmapModal';
import { useMutation } from '@tanstack/react-query';
import { httpPost } from '../../lib/query-http';
import { useToast } from '../../hooks/use-toast';
import { queryClient } from '../../stores/query-client';
type BulkUpdateResourceProgressBody = {
done: string[];
learning: string[];
skipped: string[];
pending: string[];
};
type PersonalizedRoadmapProps = {
roadmapId: string;
};
export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
const { roadmapId } = props;
const toast = useToast();
const [isModalOpen, setIsModalOpen] = useState(false);
const {
mutate: bulkUpdateResourceProgress,
isPending: isBulkUpdating,
isSuccess: isBulkUpdateSuccess,
} = useMutation(
{
mutationFn: (body: BulkUpdateResourceProgressBody) => {
return httpPost(`/v1-bulk-update-resource-progress/${roadmapId}`, body);
},
onError: (error) => {
toast.error(
error?.message ?? 'Something went wrong, please try again.',
);
},
},
queryClient,
);
const { generatePersonalizedRoadmap } = usePersonalizedRoadmap({
roadmapId,
onStart: () => {
setIsModalOpen(false);
},
onData: (data) => {
const { topicIds } = data;
topicIds.forEach((topicId) => {
renderTopicProgress(topicId, 'skipped');
});
},
onFinish: (data) => {
bulkUpdateResourceProgress({
skipped: data.topicIds,
learning: [],
done: [],
pending: [],
});
},
});
return (
<>
{isModalOpen && (
<PersonalizedRoadmapModal
roadmapId={roadmapId}
onClose={() => setIsModalOpen(false)}
onSubmit={generatePersonalizedRoadmap}
/>
)}
<button
className="group inline-flex items-center gap-1.5 border-b-2 border-b-transparent px-2 pb-2.5 text-sm font-normal text-gray-400 transition-colors hover:text-gray-700"
onClick={() => setIsModalOpen(true)}
>
<PersonStandingIcon className="h-4 w-4 shrink-0" />
<span>Personalized</span>
</button>
</>
);
}

View File

@@ -0,0 +1,48 @@
import { PersonStandingIcon } from 'lucide-react';
import { useId, useState, type FormEvent } from 'react';
type PersonalizedRoadmapFormProps = {
info?: string;
onSubmit: (info: string) => void;
};
export function PersonalizedRoadmapForm(props: PersonalizedRoadmapFormProps) {
const { info: defaultInfo, onSubmit } = props;
const [info, setInfo] = useState(defaultInfo || '');
const infoFieldId = useId();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(info);
};
return (
<form onSubmit={handleSubmit} className="p-4">
<h2 className="text-lg font-semibold">Personalize Roadmap</h2>
<div className="mt-0.5 flex flex-col gap-2">
<label htmlFor={infoFieldId} className="text-balance text-gray-600">
Tell us about yourself to personlize this roadmap as per your goals
and experience
</label>
<textarea
id={infoFieldId}
className="h-[150px] w-full resize-none rounded-xl border border-gray-200 p-3 focus:border-gray-500 focus:outline-none"
placeholder="I already know about HTML, CSS, and JavaScript..."
value={info}
onChange={(e) => setInfo(e.target.value)}
/>
</div>
<div className="mt-2 flex items-center justify-end">
<button
type="submit"
className="flex items-center gap-2 rounded-xl bg-black p-2 px-4 text-white hover:opacity-90 focus:outline-none"
>
<PersonStandingIcon className="h-4 w-4" />
Personalize
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,20 @@
import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap';
import { renderTopicProgress } from '../../lib/resource-progress';
import { Modal } from '../Modal';
import { PersonalizedRoadmapForm } from './PersonalizedRoadmapForm';
type PersonalizedRoadmapModalProps = {
roadmapId: string;
onClose: () => void;
onSubmit: (information: string) => void;
};
export function PersonalizedRoadmapModal(props: PersonalizedRoadmapModalProps) {
const { roadmapId, onClose, onSubmit } = props;
return (
<Modal onClose={onClose} bodyClassName="rounded-2xl">
<PersonalizedRoadmapForm onSubmit={onSubmit} />
</Modal>
);
}

View File

@@ -15,6 +15,7 @@ import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { ScheduleButton } from './Schedule/ScheduleButton';
import { ShareRoadmapButton } from './ShareRoadmapButton';
import { TabLink } from './TabLink';
import { PersonalizedRoadmap } from './PersonalizedRoadmap/PersonalizedRoadmap';
export interface Props {
title: string;
@@ -165,15 +166,7 @@ const hasProjects = projectCount > 0;
)}
</div>
<TabLink
url={`https://github.com/kamranahmedse/developer-roadmap/issues/new/choose`}
icon={MessageCircle}
text='Suggest Changes'
isExternal={true}
hideTextOnMobile={true}
isActive={false}
className='hidden sm:flex'
/>
<PersonalizedRoadmap roadmapId={roadmapId} client:load />
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { useCallback, useRef, useState } from 'react';
import { removeAuthToken } from '../lib/jwt';
import { readChatStream } from '../lib/chat';
import { flushSync } from 'react-dom';
type PersonalizedRoadmapResponse = {
topicIds: string[];
};
type UsePersonalizedRoadmapOptions = {
roadmapId: string;
onError?: (error: Error) => void;
onStart?: () => void;
onData?: (data: PersonalizedRoadmapResponse) => void;
onFinish?: (data: PersonalizedRoadmapResponse) => void;
};
export function usePersonalizedRoadmap(options: UsePersonalizedRoadmapOptions) {
const { roadmapId, onError, onStart, onData, onFinish } = options;
const abortControllerRef = useRef<AbortController | null>(null);
const contentRef = useRef<PersonalizedRoadmapResponse | null>(null);
const [status, setStatus] = useState<
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
>('idle');
const generatePersonalizedRoadmap = useCallback(
async (information: string) => {
try {
onStart?.();
setStatus('loading');
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-personalize-roadmap`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roadmapId,
information,
}),
signal: abortControllerRef.current?.signal,
credentials: 'include',
},
);
if (!response.ok) {
const data = await response.json();
setStatus('error');
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
throw new Error(data?.message || 'Something went wrong');
}
const stream = response.body;
if (!stream) {
setStatus('error');
throw new Error('Something went wrong');
}
await readChatStream(stream, {
onMessage: async (content) => {
flushSync(() => {
setStatus('streaming');
contentRef.current = parsePersonalizedRoadmapResponse(content);
onData?.(contentRef.current);
});
},
onMessageEnd: async () => {
flushSync(() => {
setStatus('ready');
});
},
});
setStatus('idle');
abortControllerRef.current = null;
if (!contentRef.current) {
setStatus('error');
throw new Error('Something went wrong');
}
onFinish?.(contentRef.current);
} catch (error) {
if (abortControllerRef.current?.signal.aborted) {
// we don't want to show error if the user stops the chat
// so we just return
return;
}
onError?.(error as Error);
setStatus('error');
}
},
[roadmapId, onError],
);
const stop = useCallback(() => {
if (!abortControllerRef.current) {
return;
}
abortControllerRef.current.abort();
abortControllerRef.current = null;
}, []);
return {
status,
stop,
generatePersonalizedRoadmap,
};
}
export function parsePersonalizedRoadmapResponse(
response: string,
): PersonalizedRoadmapResponse {
const topicIds: Set<string> = new Set();
const lines = response.split('\n');
for (const line of lines) {
if (!line.trim()) {
continue;
}
if (line.startsWith('-')) {
const topicId = line.slice(1).trim();
if (!topicId) {
continue;
}
topicIds.add(topicId);
}
}
return {
topicIds: Array.from(topicIds),
};
}