mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 04:59:50 +02:00
wip
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import { Loader2Icon, PersonStandingIcon } from 'lucide-react';
|
import { Loader2Icon, PersonStandingIcon } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap';
|
import { usePersonalizedRoadmap } from '../../hooks/use-personalized-roadmap';
|
||||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
import {
|
||||||
|
refreshProgressCounters,
|
||||||
|
renderTopicProgress,
|
||||||
|
} from '../../lib/resource-progress';
|
||||||
import { PersonalizedRoadmapModal } from './PersonalizedRoadmapModal';
|
import { PersonalizedRoadmapModal } from './PersonalizedRoadmapModal';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { httpPost } from '../../lib/query-http';
|
import { httpPost } from '../../lib/query-http';
|
||||||
@@ -34,36 +37,59 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allClickableNodes = useMemo(() => {
|
const { data: userProgress, refetch: refetchUserProgress } = useQuery(
|
||||||
return (
|
userResourceProgressOptions('roadmap', roadmapId),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alreadyInProgressNodeIds = useMemo(() => {
|
||||||
|
return new Set([
|
||||||
|
...(userProgress?.learning ?? []),
|
||||||
|
...(userProgress?.done ?? []),
|
||||||
|
]);
|
||||||
|
}, [userProgress]);
|
||||||
|
|
||||||
|
const allPendingNodeIds = useMemo(() => {
|
||||||
|
const nodes =
|
||||||
roadmap?.json?.nodes?.filter((node) =>
|
roadmap?.json?.nodes?.filter((node) =>
|
||||||
['topic', 'subtopic'].includes(node?.type ?? ''),
|
['topic', 'subtopic'].includes(node?.type ?? ''),
|
||||||
) ?? []
|
) ?? [];
|
||||||
);
|
|
||||||
}, [roadmap]);
|
|
||||||
|
|
||||||
const { mutate: bulkUpdateResourceProgress, isPending: isBulkUpdating } =
|
return nodes
|
||||||
useMutation(
|
.filter((node) => {
|
||||||
{
|
const topicId = node?.id;
|
||||||
mutationFn: (body: BulkUpdateResourceProgressBody) => {
|
return !alreadyInProgressNodeIds.has(topicId);
|
||||||
return httpPost(
|
})
|
||||||
`/v1-bulk-update-resource-progress/${roadmapId}`,
|
.map((node) => node?.id);
|
||||||
body,
|
}, [roadmap, alreadyInProgressNodeIds]);
|
||||||
);
|
|
||||||
},
|
const clearResourceProgressLocalStorage = useCallback(() => {
|
||||||
onError: (error) => {
|
localStorage.removeItem(`roadmap-${roadmapId}-${currentUser?.id}-progress`);
|
||||||
toast.error(
|
localStorage.removeItem(`roadmap-${roadmapId}-${currentUser?.id}-favorite`);
|
||||||
error?.message ?? 'Something went wrong, please try again.',
|
}, [roadmapId, currentUser]);
|
||||||
);
|
|
||||||
},
|
const {
|
||||||
onSuccess: () => {
|
mutate: bulkUpdateResourceProgress,
|
||||||
queryClient.invalidateQueries(
|
isPending: isBulkUpdating,
|
||||||
userResourceProgressOptions('roadmap', roadmapId),
|
mutateAsync: bulkUpdateResourceProgressAsync,
|
||||||
);
|
} = useMutation(
|
||||||
},
|
{
|
||||||
|
mutationFn: (body: BulkUpdateResourceProgressBody) => {
|
||||||
|
return httpPost(`/v1-bulk-update-resource-progress/${roadmapId}`, body);
|
||||||
},
|
},
|
||||||
queryClient,
|
onError: (error) => {
|
||||||
);
|
toast.error(
|
||||||
|
error?.message ?? 'Something went wrong, please try again.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
clearResourceProgressLocalStorage();
|
||||||
|
refetchUserProgress();
|
||||||
|
refreshProgressCounters();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
const { generatePersonalizedRoadmap, status } = usePersonalizedRoadmap({
|
const { generatePersonalizedRoadmap, status } = usePersonalizedRoadmap({
|
||||||
roadmapId,
|
roadmapId,
|
||||||
@@ -73,14 +99,18 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
const { topicIds } = data;
|
const { topicIds } = data;
|
||||||
topicIds.forEach((topicId) => {
|
topicIds.forEach((topicId) => {
|
||||||
|
if (alreadyInProgressNodeIds.has(topicId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderTopicProgress(topicId, 'pending');
|
renderTopicProgress(topicId, 'pending');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onFinish: (data) => {
|
onFinish: (data) => {
|
||||||
const { topicIds } = data;
|
const { topicIds } = data;
|
||||||
const remainingTopicIds = allClickableNodes
|
const remainingTopicIds = allPendingNodeIds.filter(
|
||||||
.filter((node) => !topicIds.includes(node?.id ?? ''))
|
(nodeId) => !topicIds.includes(nodeId),
|
||||||
.map((node) => node?.id ?? '');
|
);
|
||||||
|
|
||||||
bulkUpdateResourceProgress({
|
bulkUpdateResourceProgress({
|
||||||
skipped: remainingTopicIds,
|
skipped: remainingTopicIds,
|
||||||
@@ -93,10 +123,12 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
|
|
||||||
const { mutate: clearResourceProgress, isPending: isClearing } = useMutation(
|
const { mutate: clearResourceProgress, isPending: isClearing } = useMutation(
|
||||||
{
|
{
|
||||||
mutationFn: () => {
|
mutationFn: (pendingTopicIds: string[]) => {
|
||||||
return httpPost(`/v1-clear-resource-progress`, {
|
return bulkUpdateResourceProgressAsync({
|
||||||
resourceId: roadmapId,
|
skipped: [],
|
||||||
resourceType: 'roadmap',
|
learning: [],
|
||||||
|
done: [],
|
||||||
|
pending: pendingTopicIds,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -104,15 +136,15 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
error?.message ?? 'Something went wrong, please try again.',
|
error?.message ?? 'Something went wrong, please try again.',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (_, pendingTopicIds) => {
|
||||||
|
for (const topicId of pendingTopicIds) {
|
||||||
|
renderTopicProgress(topicId, 'pending');
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('Progress cleared successfully.');
|
toast.success('Progress cleared successfully.');
|
||||||
localStorage.removeItem(
|
clearResourceProgressLocalStorage();
|
||||||
`roadmap-${roadmapId}-${currentUser?.id}-progress`,
|
refreshProgressCounters();
|
||||||
);
|
refetchUserProgress();
|
||||||
localStorage.removeItem(
|
|
||||||
`roadmap-${roadmapId}-${currentUser?.id}-favorite`,
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -126,15 +158,16 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
<PersonalizedRoadmapModal
|
<PersonalizedRoadmapModal
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onSubmit={(information) => {
|
onSubmit={(information) => {
|
||||||
for (const node of allClickableNodes) {
|
for (const nodeId of allPendingNodeIds) {
|
||||||
renderTopicProgress(node?.id, 'skipped');
|
renderTopicProgress(nodeId, 'skipped');
|
||||||
}
|
}
|
||||||
|
|
||||||
generatePersonalizedRoadmap(information);
|
generatePersonalizedRoadmap(information);
|
||||||
}}
|
}}
|
||||||
onClearProgress={() => {
|
onClearProgress={() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
clearResourceProgress();
|
const prevSkipped = userProgress?.skipped ?? [];
|
||||||
|
clearResourceProgress(prevSkipped);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -145,11 +178,16 @@ export function PersonalizedRoadmap(props: PersonalizedRoadmapProps) {
|
|||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<Loader2Icon className="h-4 w-4 shrink-0 animate-spin" />
|
<>
|
||||||
|
<Loader2Icon className="h-4 w-4 shrink-0 animate-spin" />
|
||||||
|
<span>Personalizing...</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<PersonStandingIcon className="h-4 w-4 shrink-0" />
|
<>
|
||||||
|
<PersonStandingIcon className="h-4 w-4 shrink-0" />
|
||||||
|
<span>Personalize</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<span>Personalized</span>
|
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@@ -39,15 +39,15 @@ export function PersonalizedRoadmapForm(props: PersonalizedRoadmapFormProps) {
|
|||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center justify-center gap-2 rounded-xl border border-gray-200 p-2 px-4 text-gray-600 hover:bg-gray-100 focus:outline-none"
|
className="flex items-center justify-center gap-2 rounded-xl border border-gray-200 p-2 px-2 text-gray-600 hover:bg-gray-100 focus:outline-none"
|
||||||
onClick={onClearProgress}
|
onClick={onClearProgress}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
Clear Progress
|
Clear Personalized
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex items-center justify-center gap-2 rounded-xl bg-black p-2 px-4 text-white hover:opacity-90 focus:outline-none"
|
className="flex items-center justify-center gap-2 rounded-xl bg-black p-2 px-2 text-white hover:opacity-90 focus:outline-none"
|
||||||
>
|
>
|
||||||
<PersonStandingIcon className="h-4 w-4" />
|
<PersonStandingIcon className="h-4 w-4" />
|
||||||
Personalize
|
Personalize
|
||||||
|
@@ -26,84 +26,81 @@ export function usePersonalizedRoadmap(options: UsePersonalizedRoadmapOptions) {
|
|||||||
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
|
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
|
||||||
>('idle');
|
>('idle');
|
||||||
|
|
||||||
const generatePersonalizedRoadmap = useCallback(
|
const generatePersonalizedRoadmap = async (information: string) => {
|
||||||
async (information: string) => {
|
try {
|
||||||
try {
|
onStart?.();
|
||||||
onStart?.();
|
setStatus('loading');
|
||||||
setStatus('loading');
|
abortControllerRef.current?.abort();
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current = new AbortController();
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-personalized-roadmap`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-personalized-roadmap`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
roadmapId,
|
|
||||||
information,
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current?.signal,
|
|
||||||
credentials: 'include',
|
|
||||||
},
|
},
|
||||||
);
|
body: JSON.stringify({
|
||||||
|
roadmapId,
|
||||||
|
information,
|
||||||
|
}),
|
||||||
|
signal: abortControllerRef.current?.signal,
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
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');
|
setStatus('error');
|
||||||
|
if (data.status === 401) {
|
||||||
|
removeAuthToken();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data?.message || 'Something went wrong');
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[roadmapId, onError],
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!abortControllerRef.current) {
|
if (!abortControllerRef.current) {
|
||||||
|
@@ -4,6 +4,8 @@ import { TOKEN_COOKIE_NAME, getUser } from './jwt';
|
|||||||
import { roadmapProgress, totalRoadmapNodes } from '../stores/roadmap.ts';
|
import { roadmapProgress, totalRoadmapNodes } from '../stores/roadmap.ts';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Element = astroHTML.JSX.Element;
|
import Element = astroHTML.JSX.Element;
|
||||||
|
import { queryClient } from '../stores/query-client.ts';
|
||||||
|
import { userResourceProgressOptions } from '../queries/resource-progress.ts';
|
||||||
|
|
||||||
export type ResourceType = 'roadmap' | 'best-practice';
|
export type ResourceType = 'roadmap' | 'best-practice';
|
||||||
export type ResourceProgressType =
|
export type ResourceProgressType =
|
||||||
@@ -78,6 +80,22 @@ export async function updateResourceProgress(
|
|||||||
response.skipped,
|
response.skipped,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
queryClient.setQueryData(
|
||||||
|
userResourceProgressOptions(resourceType, resourceId).queryKey,
|
||||||
|
(oldData) => {
|
||||||
|
if (!oldData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
done: response.done,
|
||||||
|
learning: response.learning,
|
||||||
|
skipped: response.skipped,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user