mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 08:35:42 +02:00
wip
This commit is contained in:
@@ -16,6 +16,7 @@ import { queryClient } from '../../stores/query-client';
|
|||||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, type RefObject } from 'react';
|
import { useEffect, type RefObject } from 'react';
|
||||||
|
import { roadmapDetailsOptions } from '../../queries/roadmap';
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
DocumentExtension,
|
DocumentExtension,
|
||||||
@@ -44,6 +45,10 @@ export function ChatEditor(props: ChatEditorProps) {
|
|||||||
roadmapTreeMappingOptions(roadmapId),
|
roadmapTreeMappingOptions(roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
const { data: roadmapDetailsData } = useQuery(
|
||||||
|
roadmapDetailsOptions(roadmapId),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
@@ -93,17 +98,20 @@ export function ChatEditor(props: ChatEditorProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !roadmapTreeData) {
|
if (!editor || !roadmapTreeData || !roadmapDetailsData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.storage.variable.variables = roadmapTreeData.map((mapping) => {
|
editor.storage.variable.variables = roadmapTreeData.map((mapping) => {
|
||||||
return {
|
return {
|
||||||
id: mapping._id,
|
id: mapping.nodeId,
|
||||||
label: mapping.text,
|
// to remove the title of the roadmap
|
||||||
|
// and only keep the path
|
||||||
|
// e.g. "Roadmap > Topic > Subtopic" -> "Topic > Subtopic"
|
||||||
|
label: mapping.text.split(' > ').slice(1).join(' > '),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [editor, roadmapTreeData]);
|
}, [editor, roadmapTreeData, roadmapDetailsData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-editor w-full">
|
<div className="chat-editor w-full">
|
||||||
|
68
src/components/RoadmapAIChat/ChatRoadmapRenderer.css
Normal file
68
src/components/RoadmapAIChat/ChatRoadmapRenderer.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
svg text tspan {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > g[data-type='topic'],
|
||||||
|
svg > g[data-type='subtopic'],
|
||||||
|
svg g[data-type='link-item'],
|
||||||
|
svg > g[data-type='button'],
|
||||||
|
svg > g[data-type='resourceButton'],
|
||||||
|
svg > g[data-type='todo-checkbox'],
|
||||||
|
svg > g[data-type='todo'],
|
||||||
|
svg > g[data-type='checklist'] > g[data-type='checklist-item'] > rect {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > g[data-type='topic']:hover > rect {
|
||||||
|
fill: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > g[data-type='subtopic']:hover > rect {
|
||||||
|
fill: var(--hover-color);
|
||||||
|
}
|
||||||
|
svg g[data-type='button']:hover,
|
||||||
|
svg g[data-type='link-item']:hover,
|
||||||
|
svg g[data-type='resourceButton']:hover,
|
||||||
|
svg g[data-type='todo-checkbox']:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg g[data-type='checklist'] > g[data-type='checklist-item'] > rect:hover {
|
||||||
|
fill: #cbcbcb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .done rect {
|
||||||
|
fill: #cbcbcb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .done text,
|
||||||
|
svg .skipped text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > g[data-type='topic'].learning > rect + text,
|
||||||
|
svg > g[data-type='topic'].done > rect + text {
|
||||||
|
fill: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .done text[fill='#ffffff'] {
|
||||||
|
fill: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > g[data-type='subtipic'].done > rect + text,
|
||||||
|
svg > g[data-type='subtipic'].learning > rect + text {
|
||||||
|
fill: #cbcbcb;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .learning rect {
|
||||||
|
fill: #dad1fd !important;
|
||||||
|
}
|
||||||
|
svg .learning text {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg .skipped rect {
|
||||||
|
fill: #496b69 !important;
|
||||||
|
}
|
258
src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx
Normal file
258
src/components/RoadmapAIChat/ChatRoadmapRenderer.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import './ChatRoadmapRenderer.css';
|
||||||
|
|
||||||
|
import { lazy, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
renderResourceProgress,
|
||||||
|
updateResourceProgress,
|
||||||
|
type ResourceProgressType,
|
||||||
|
renderTopicProgress,
|
||||||
|
refreshProgressCounters,
|
||||||
|
} from '../../lib/resource-progress';
|
||||||
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import type { Edge, Node } from '@roadmapsh/editor';
|
||||||
|
import { slugify } from '../../lib/slugger';
|
||||||
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
|
import { queryClient } from '../../stores/query-client';
|
||||||
|
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const Renderer = lazy(() =>
|
||||||
|
import('@roadmapsh/editor').then((mod) => ({
|
||||||
|
default: mod.Renderer,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
type RoadmapNodeDetails = {
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: string;
|
||||||
|
targetGroup: SVGElement;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNodeDetails(svgElement: SVGElement): RoadmapNodeDetails | null {
|
||||||
|
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||||||
|
|
||||||
|
const nodeId = targetGroup?.dataset?.nodeId;
|
||||||
|
const nodeType = targetGroup?.dataset?.type;
|
||||||
|
const title = targetGroup?.dataset?.title;
|
||||||
|
|
||||||
|
if (!nodeId || !nodeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodeId, nodeType, targetGroup, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedNodeTypes = [
|
||||||
|
'topic',
|
||||||
|
'subtopic',
|
||||||
|
'button',
|
||||||
|
'link-item',
|
||||||
|
'resourceButton',
|
||||||
|
'todo',
|
||||||
|
'todo-checkbox',
|
||||||
|
'checklist-item',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ChatRoadmapRendererProps = {
|
||||||
|
roadmapId: string;
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatRoadmapRenderer(props: ChatRoadmapRendererProps) {
|
||||||
|
const { roadmapId, nodes = [], edges = [] } = props;
|
||||||
|
const roadmapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { data: userResourceProgressData } = useQuery(
|
||||||
|
userResourceProgressOptions('roadmap', roadmapId),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function updateTopicStatus(
|
||||||
|
topicId: string,
|
||||||
|
newStatus: ResourceProgressType,
|
||||||
|
) {
|
||||||
|
pageProgressMessage.set('Updating progress');
|
||||||
|
updateResourceProgress(
|
||||||
|
{
|
||||||
|
resourceId: roadmapId,
|
||||||
|
resourceType: 'roadmap',
|
||||||
|
topicId,
|
||||||
|
},
|
||||||
|
newStatus,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
renderTopicProgress(topicId, newStatus);
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
userResourceProgressOptions('roadmap', roadmapId),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Something went wrong, please try again.');
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSvgClick = useCallback((e: MouseEvent) => {
|
||||||
|
const target = e.target as SVGElement;
|
||||||
|
const { nodeId, nodeType, targetGroup, title } =
|
||||||
|
getNodeDetails(target) || {};
|
||||||
|
|
||||||
|
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nodeType === 'button' ||
|
||||||
|
nodeType === 'link-item' ||
|
||||||
|
nodeType === 'resourceButton'
|
||||||
|
) {
|
||||||
|
const link = targetGroup?.dataset?.link || '';
|
||||||
|
const isExternalLink = link.startsWith('http');
|
||||||
|
if (isExternalLink) {
|
||||||
|
window.open(link, '_blank');
|
||||||
|
} else {
|
||||||
|
window.location.href = link;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
|
||||||
|
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
|
||||||
|
|
||||||
|
if (nodeType === 'todo-checkbox') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = targetGroup?.classList.contains('done')
|
||||||
|
? 'pending'
|
||||||
|
: 'done';
|
||||||
|
updateTopicStatus(nodeId, newStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTopicStatus(
|
||||||
|
nodeId,
|
||||||
|
isCurrentStatusLearning ? 'pending' : 'learning',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// for the click on rect of checklist-item
|
||||||
|
if (nodeType === 'checklist-item' && target.tagName === 'rect') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = targetGroup?.classList.contains('done')
|
||||||
|
? 'pending'
|
||||||
|
: 'done';
|
||||||
|
updateTopicStatus(nodeId, newStatus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we don't have the topic popup for checklist-item
|
||||||
|
if (nodeType === 'checklist-item') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const target = e.target as SVGElement;
|
||||||
|
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||||
|
if (!nodeId || !nodeType || !allowedNodeTypes.includes(nodeType)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeType === 'button') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||||
|
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roadmapRef?.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roadmapRef?.current?.addEventListener('click', handleSvgClick);
|
||||||
|
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
|
||||||
|
roadmapRef?.current?.removeEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
handleSvgRightClick,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Renderer
|
||||||
|
ref={roadmapRef}
|
||||||
|
roadmap={{ nodes, edges }}
|
||||||
|
onRendered={() => {
|
||||||
|
roadmapRef.current?.setAttribute('data-renderer', 'editor');
|
||||||
|
|
||||||
|
if (!userResourceProgressData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { done, learning, skipped } = userResourceProgressData;
|
||||||
|
done.forEach((topicId) => {
|
||||||
|
renderTopicProgress(topicId, 'done');
|
||||||
|
});
|
||||||
|
|
||||||
|
learning.forEach((topicId) => {
|
||||||
|
renderTopicProgress(topicId, 'learning');
|
||||||
|
});
|
||||||
|
|
||||||
|
skipped.forEach((topicId) => {
|
||||||
|
renderTopicProgress(topicId, 'skipped');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,8 +1,12 @@
|
|||||||
|
import './RoadmapAIChat.css';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
import {
|
||||||
|
roadmapDetailsOptions,
|
||||||
|
roadmapJSONOptions,
|
||||||
|
} from '../../queries/roadmap';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
|
||||||
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react';
|
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react';
|
||||||
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
import { ChatEditor } from '../ChatEditor/ChatEditor';
|
||||||
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
|
||||||
@@ -18,8 +22,13 @@ import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
|||||||
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
|
import { markdownToHtmlWithHighlighting } from '../../lib/markdown';
|
||||||
import { readStream } from '../../lib/ai';
|
import { readStream } from '../../lib/ai';
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { userResourceProgressOptions } from '../../queries/resource-progress';
|
||||||
|
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||||
|
import { EditorRoadmapRenderer } from '../EditorRoadmap/EditorRoadmapRenderer';
|
||||||
|
import { ChatRoadmapRenderer } from './ChatRoadmapRenderer';
|
||||||
|
|
||||||
export type RoamdapAIChatHistoryType = AIChatHistoryType & {
|
export type RoamdapAIChatHistoryType = AIChatHistoryType & {
|
||||||
|
|
||||||
json?: JSONContent;
|
json?: JSONContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +51,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||||
const [streamedMessage, setStreamedMessage] = useState('');
|
const [streamedMessage, setStreamedMessage] = useState('');
|
||||||
|
|
||||||
|
const { data: roadmapDetailsData } = useQuery(
|
||||||
|
roadmapDetailsOptions(roadmapId),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
const { data: roadmapJSONData } = useQuery(
|
const { data: roadmapJSONData } = useQuery(
|
||||||
roadmapJSONOptions(roadmapId),
|
roadmapJSONOptions(roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -51,6 +65,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: userResourceProgressData } = useQuery(
|
||||||
|
userResourceProgressOptions('roadmap', roadmapId),
|
||||||
|
queryClient,
|
||||||
|
);
|
||||||
|
|
||||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -62,12 +81,12 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
}, [roadmapJSONData]);
|
}, [roadmapJSONData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roadmapTreeData || !roadmapJSONData) {
|
if (!roadmapTreeData || !roadmapJSONData || !roadmapDetailsData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [roadmapTreeData, roadmapJSONData]);
|
}, [roadmapTreeData, roadmapJSONData, roadmapDetailsData]);
|
||||||
|
|
||||||
const handleChatSubmit = (json: JSONContent) => {
|
const handleChatSubmit = (json: JSONContent) => {
|
||||||
if (!json || isStreamingMessage || !isLoggedIn() || isLoading) {
|
if (!json || isStreamingMessage || !isLoggedIn() || isLoading) {
|
||||||
@@ -183,17 +202,23 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grow grid-cols-3">
|
<div className="grid grow grid-cols-5">
|
||||||
<div className="relative col-span-2 h-full overflow-y-scroll">
|
<div className="relative col-span-3 h-full overflow-y-scroll">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
|
||||||
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
<Loader2Icon className="size-6 animate-spin stroke-[2.5]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={roadmapContainerRef} hidden={isLoading} className="p-4" />
|
{roadmapJSONData?.json && !isLoading && (
|
||||||
|
<ChatRoadmapRenderer
|
||||||
|
roadmapId={roadmapId}
|
||||||
|
nodes={roadmapJSONData?.json.nodes}
|
||||||
|
edges={roadmapJSONData?.json.edges}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-col border-l border-gray-200 bg-white">
|
<div className="col-span-2 flex h-full flex-col border-l border-gray-200 bg-white">
|
||||||
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
|
<div className="flex min-h-[46px] items-center justify-between gap-2 border-b border-gray-200 px-3 py-2 text-sm">
|
||||||
<span className="flex items-center gap-2 text-sm">
|
<span className="flex items-center gap-2 text-sm">
|
||||||
<BotIcon className="size-4 shrink-0 text-black" />
|
<BotIcon className="size-4 shrink-0 text-black" />
|
||||||
@@ -281,9 +306,6 @@ export function htmlFromTiptapJSON(json: JSONContent) {
|
|||||||
text += child.text;
|
text += child.text;
|
||||||
break;
|
break;
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
// Add a new line before each paragraph
|
|
||||||
// This is to ensure that the text is formatted correctly
|
|
||||||
text += '\n';
|
|
||||||
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
|
text += `<p>${htmlFromTiptapJSON(child)}</p>`;
|
||||||
break;
|
break;
|
||||||
case 'variable':
|
case 'variable':
|
||||||
|
28
src/queries/resource-progress.ts
Normal file
28
src/queries/resource-progress.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { httpGet } from '../lib/query-http';
|
||||||
|
|
||||||
|
export type GetUserResourceProgressResponse = {
|
||||||
|
done: string[];
|
||||||
|
learning: string[];
|
||||||
|
skipped: string[];
|
||||||
|
isFavorite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function userResourceProgressOptions(
|
||||||
|
resourceType: string,
|
||||||
|
resourceId: string,
|
||||||
|
) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['resource-progress', resourceId, resourceType],
|
||||||
|
queryFn: () => {
|
||||||
|
return httpGet<GetUserResourceProgressResponse>(
|
||||||
|
`/v1-get-user-resource-progress`,
|
||||||
|
{
|
||||||
|
resourceId,
|
||||||
|
resourceType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
}
|
@@ -21,3 +21,41 @@ export function roadmapJSONOptions(roadmapId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const allowedRoadmapRenderer = [
|
||||||
|
'balsamiq',
|
||||||
|
'editor',
|
||||||
|
'infinite-canvas',
|
||||||
|
] as const;
|
||||||
|
export type AllowedRoadmapRenderer = (typeof allowedRoadmapRenderer)[number];
|
||||||
|
|
||||||
|
export type PagesJSON = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
group: string;
|
||||||
|
authorId?: string;
|
||||||
|
renderer?: AllowedRoadmapRenderer;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export function roadmapDetailsOptions(roadmapId: string) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ['roadmap-details', roadmapId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const baseUrl = import.meta.env.PUBLIC_APP_URL;
|
||||||
|
const pagesJSON = await httpGet<PagesJSON>(`${baseUrl}/pages.json`);
|
||||||
|
|
||||||
|
const roadmapDetails = pagesJSON.find(
|
||||||
|
(page) =>
|
||||||
|
page?.group?.toLowerCase() === 'roadmaps' && page.id === roadmapId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roadmapDetails) {
|
||||||
|
throw new Error('Roadmap details not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return roadmapDetails;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user