mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 21:11:44 +02:00
feat: add ai roadmap slug (#5529)
* Update * Add stats and health endpoints * Add pre-render * fix: redirect to the error page * Fix generate-renderer issue * Rename * Fix best practice topics not loading * Handle SSR for static pages * Refactor faqs * Refactor best practices * Fix absolute import * Fix stats * Add custom roadmap page * Minor UI change * feat: custom roadmap slug routes (#4987) * feat: replace roadmap slug * fix: remove roadmap slug * feat: username route * fix: user public page * feat: show roadmap progress * feat: update public profile * fix: replace with toast * feat: user public profile page * feat: implement profile form * feat: implement user profile roadmap page * refactor: remove logs * fix: increase progress gap * fix: remove title margin * fix: breakpoint for roadmaps * Update dependencies * Upgrade dependencies * fix: improper avatars * fix: heatmap focus * wip: remove `getStaticPaths` * fix: add disable props * wip * feat: add email icon * fix: update pnpm lock * fix: implement author page * Fix beginner roadmaps not working * Changes to form * Refactor profile and form * Refactor public profile form * Rearrange sidebar items * Update UI for public form * Minor text update * Refactor public profile form * Error page for user * Revamp UI for profile page * Add public profile page * Fix vite warnings * Add private profile banner * feat: on blur check username * Update fetch depth * Add error detail * Use hybrid mode of rendering * Do not pre-render stats pages * Update deployment workflow * Update deployment workflow * wip * wip * wip * feat: add slug navigation * feat: add ai roadmap slug * feat: add explore page slug --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
20
src/api/ai-roadmap.ts
Normal file
20
src/api/ai-roadmap.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { type APIContext } from 'astro';
|
||||||
|
import { api } from './api.ts';
|
||||||
|
|
||||||
|
export type GetAIRoadmapBySlugResponse = {
|
||||||
|
id: string;
|
||||||
|
term: string;
|
||||||
|
title: string;
|
||||||
|
data: string;
|
||||||
|
isAuthenticatedUser: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function aiRoadmapApi(context: APIContext) {
|
||||||
|
return {
|
||||||
|
getAIRoadmapBySlug: async function (roadmapSlug: string) {
|
||||||
|
return api(context).get<GetAIRoadmapBySlugResponse>(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap-by-slug/${roadmapSlug}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@@ -26,7 +26,8 @@ export function AIRoadmapsList(props: AIRoadmapsListProps) {
|
|||||||
return (
|
return (
|
||||||
<ul className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{roadmaps.map((roadmap) => {
|
{roadmaps.map((roadmap) => {
|
||||||
const roadmapLink = `/ai?id=${roadmap._id}`;
|
const roadmapLink = `/ai/${roadmap.slug}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={roadmap._id}
|
key={roadmap._id}
|
||||||
|
@@ -19,6 +19,7 @@ export interface AIRoadmapDocument {
|
|||||||
term: string;
|
term: string;
|
||||||
title: string;
|
title: string;
|
||||||
data: string;
|
data: string;
|
||||||
|
slug: string;
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
@@ -50,6 +50,7 @@ export type GetAIRoadmapLimitResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@');
|
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@');
|
||||||
|
const ROADMAP_SLUG_REGEX = new RegExp(/@ROADMAPSLUG:([\w-]+)@/);
|
||||||
|
|
||||||
export type RoadmapNodeDetails = {
|
export type RoadmapNodeDetails = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -87,22 +88,39 @@ type GetAIRoadmapResponse = {
|
|||||||
data: string;
|
data: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateRoadmap() {
|
type GenerateRoadmapProps = {
|
||||||
|
roadmapId?: string;
|
||||||
|
slug?: string;
|
||||||
|
isAuthenticatedUser?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GenerateRoadmap(props: GenerateRoadmapProps) {
|
||||||
|
const {
|
||||||
|
roadmapId: defaultRoadmapId,
|
||||||
|
slug: defaultRoadmapSlug,
|
||||||
|
isAuthenticatedUser = isLoggedIn(),
|
||||||
|
} = props;
|
||||||
|
|
||||||
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
const roadmapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { id: roadmapId, rc: referralCode } = getUrlParams() as {
|
const { rc: referralCode } = getUrlParams() as {
|
||||||
id: string;
|
|
||||||
rc?: string;
|
rc?: string;
|
||||||
};
|
};
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
|
const [roadmapId, setRoadmapId] = useState<string | undefined>(
|
||||||
|
defaultRoadmapId,
|
||||||
|
);
|
||||||
|
const [roadmapSlug, setRoadmapSlug] = useState<string | undefined>(
|
||||||
|
defaultRoadmapSlug,
|
||||||
|
);
|
||||||
|
const [hasSubmitted, setHasSubmitted] = useState<boolean>(Boolean(roadmapId));
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
const [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||||
const [roadmapTerm, setRoadmapTerm] = useState('');
|
const [roadmapTerm, setRoadmapTerm] = useState('');
|
||||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
|
||||||
const [currentRoadmap, setCurrentRoadmap] =
|
const [currentRoadmap, setCurrentRoadmap] =
|
||||||
useState<GetAIRoadmapResponse | null>(null);
|
useState<GetAIRoadmapResponse | null>(null);
|
||||||
|
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||||
const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>(
|
const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -117,7 +135,6 @@ export function GenerateRoadmap() {
|
|||||||
getOpenAIKey(),
|
getOpenAIKey(),
|
||||||
);
|
);
|
||||||
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
||||||
const isAuthenticatedUser = isLoggedIn();
|
|
||||||
|
|
||||||
const renderRoadmap = async (roadmap: string) => {
|
const renderRoadmap = async (roadmap: string) => {
|
||||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
||||||
@@ -134,6 +151,7 @@ export function GenerateRoadmap() {
|
|||||||
deleteUrlParam('id');
|
deleteUrlParam('id');
|
||||||
setCurrentRoadmap(null);
|
setCurrentRoadmap(null);
|
||||||
|
|
||||||
|
const origin = window.location.origin;
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
||||||
{
|
{
|
||||||
@@ -169,13 +187,31 @@ export function GenerateRoadmap() {
|
|||||||
|
|
||||||
await readAIRoadmapStream(reader, {
|
await readAIRoadmapStream(reader, {
|
||||||
onStream: async (result) => {
|
onStream: async (result) => {
|
||||||
if (result.includes('@ROADMAPID')) {
|
if (result.includes('@ROADMAPID') || result.includes('@ROADMAPSLUG')) {
|
||||||
// @ROADMAPID: is a special token that we use to identify the roadmap
|
// @ROADMAPID: is a special token that we use to identify the roadmap
|
||||||
// @ROADMAPID:1234@ is the format, we will remove the token and the id
|
// @ROADMAPID:1234@ is the format, we will remove the token and the id
|
||||||
// and replace it with a empty string
|
// and replace it with a empty string
|
||||||
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||||
setUrlParams({ id: roadmapId });
|
const roadmapSlug = result.match(ROADMAP_SLUG_REGEX)?.[1] || '';
|
||||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
|
||||||
|
if (roadmapSlug) {
|
||||||
|
window.history.pushState(
|
||||||
|
{
|
||||||
|
roadmapId,
|
||||||
|
roadmapSlug,
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
`${origin}/ai/${roadmapSlug}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result
|
||||||
|
.replace(ROADMAP_ID_REGEX, '')
|
||||||
|
.replace(ROADMAP_SLUG_REGEX, '');
|
||||||
|
|
||||||
|
setRoadmapId(roadmapId);
|
||||||
|
setRoadmapSlug(roadmapSlug);
|
||||||
|
|
||||||
const roadmapTitle =
|
const roadmapTitle =
|
||||||
result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
|
result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
|
||||||
setRoadmapTerm(roadmapTitle);
|
setRoadmapTerm(roadmapTitle);
|
||||||
@@ -190,7 +226,10 @@ export function GenerateRoadmap() {
|
|||||||
await renderRoadmap(result);
|
await renderRoadmap(result);
|
||||||
},
|
},
|
||||||
onStreamEnd: async (result) => {
|
onStreamEnd: async (result) => {
|
||||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
result = result
|
||||||
|
.replace(ROADMAP_ID_REGEX, '')
|
||||||
|
.replace(ROADMAP_SLUG_REGEX, '');
|
||||||
|
|
||||||
setGeneratedRoadmapContent(result);
|
setGeneratedRoadmapContent(result);
|
||||||
loadAIRoadmapLimit().finally(() => {});
|
loadAIRoadmapLimit().finally(() => {});
|
||||||
},
|
},
|
||||||
@@ -322,7 +361,7 @@ export function GenerateRoadmap() {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
setRoadmapTerm(term);
|
setRoadmapTerm(title || term);
|
||||||
setGeneratedRoadmapContent(data);
|
setGeneratedRoadmapContent(data);
|
||||||
visitAIRoadmap(roadmapId);
|
visitAIRoadmap(roadmapId);
|
||||||
};
|
};
|
||||||
@@ -385,12 +424,35 @@ export function GenerateRoadmap() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasSubmitted(true);
|
|
||||||
loadAIRoadmap(roadmapId).finally(() => {
|
loadAIRoadmap(roadmapId).finally(() => {
|
||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
});
|
});
|
||||||
}, [roadmapId, currentRoadmap]);
|
}, [roadmapId, currentRoadmap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePopState = (e: PopStateEvent) => {
|
||||||
|
const { roadmapId, roadmapSlug } = e.state || {};
|
||||||
|
if (!roadmapId || !roadmapSlug) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasSubmitted(true);
|
||||||
|
setRoadmapId(roadmapId);
|
||||||
|
setRoadmapSlug(roadmapSlug);
|
||||||
|
loadAIRoadmap(roadmapId).finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handlePopState);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!hasSubmitted) {
|
if (!hasSubmitted) {
|
||||||
return (
|
return (
|
||||||
<RoadmapSearch
|
<RoadmapSearch
|
||||||
@@ -401,7 +463,7 @@ export function GenerateRoadmap() {
|
|||||||
limitUsed={roadmapLimitUsed}
|
limitUsed={roadmapLimitUsed}
|
||||||
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
||||||
isKeyOnly={isKeyOnly}
|
isKeyOnly={isKeyOnly}
|
||||||
onLoadTerm={(term: string) => {
|
onLoadTerm={(term) => {
|
||||||
setRoadmapTerm(term);
|
setRoadmapTerm(term);
|
||||||
loadTermRoadmap(term).finally(() => {});
|
loadTermRoadmap(term).finally(() => {});
|
||||||
}}
|
}}
|
||||||
@@ -409,7 +471,7 @@ export function GenerateRoadmap() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageUrl = `https://roadmap.sh/ai?id=${roadmapId}`;
|
const pageUrl = `https://roadmap.sh/ai/${roadmapSlug}`;
|
||||||
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
const canGenerateMore = roadmapLimitUsed < roadmapLimit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -524,7 +586,7 @@ export function GenerateRoadmap() {
|
|||||||
)}
|
)}
|
||||||
{!isAuthenticatedUser && (
|
{!isAuthenticatedUser && (
|
||||||
<button
|
<button
|
||||||
className="rounded-xl border border-current px-2.5 py-0.5 text-left text-sm font-medium text-blue-500 transition-colors hover:bg-blue-500 hover:text-white sm:text-center"
|
className="mt-2 rounded-xl border border-current px-2.5 py-0.5 text-left text-sm font-medium text-blue-500 transition-colors hover:bg-blue-500 hover:text-white sm:text-center"
|
||||||
onClick={showLoginPopup}
|
onClick={showLoginPopup}
|
||||||
>
|
>
|
||||||
Login to generate your own roadmaps
|
Login to generate your own roadmaps
|
||||||
|
@@ -3,14 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||||
import { markdownToHtml } from '../../lib/markdown';
|
import { markdownToHtml } from '../../lib/markdown';
|
||||||
import {Ban, Cog, Contact, FileText, User, UserRound, X} from 'lucide-react';
|
import { Ban, Cog, Contact, FileText, User, UserRound, X } from 'lucide-react';
|
||||||
import { Spinner } from '../ReactIcons/Spinner';
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
import type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
|
||||||
|
|
||||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -179,19 +178,19 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoggedIn() && (
|
{!isLoggedIn() && (
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
<Contact className="h-14 w-14 text-gray-200 mb-3.5" />
|
<Contact className="mb-3.5 h-14 w-14 text-gray-200" />
|
||||||
<h2 className='font-medium text-xl'>You must be logged in</h2>
|
<h2 className="text-xl font-medium">You must be logged in</h2>
|
||||||
<p className="text-base text-gray-400">
|
<p className="text-base text-gray-400">
|
||||||
Sign up or login to generate topic content.
|
Sign up or login to generate topic content.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="mt-3.5 text-base font-medium text-white bg-black px-3 py-2 rounded-md w-full max-w-[300px]"
|
className="mt-3.5 w-full max-w-[300px] rounded-md bg-black px-3 py-2 text-base font-medium text-white"
|
||||||
onClick={showLoginPopup}
|
onClick={showLoginPopup}
|
||||||
>
|
>
|
||||||
Sign up / Login
|
Sign up / Login
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
|
34
src/pages/ai/[aiRoadmapSlug].astro
Normal file
34
src/pages/ai/[aiRoadmapSlug].astro
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
import { aiRoadmapApi } from '../../api/ai-roadmap';
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { GenerateRoadmap } from '../../components/GenerateRoadmap/GenerateRoadmap';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
interface Params extends Record<string, string | undefined> {
|
||||||
|
aiRoadmapSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { aiRoadmapSlug } = Astro.params as Params;
|
||||||
|
if (!aiRoadmapSlug) {
|
||||||
|
return Astro.redirect('/404');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiRoadmapClient = aiRoadmapApi(Astro as any);
|
||||||
|
const { response: roadmap, error } =
|
||||||
|
await aiRoadmapClient.getAIRoadmapBySlug(aiRoadmapSlug);
|
||||||
|
|
||||||
|
let errorMessage = '';
|
||||||
|
if (error || !roadmap) {
|
||||||
|
errorMessage = error?.message || 'Error loading AI Roadmap';
|
||||||
|
}
|
||||||
|
const title = roadmap?.title || 'Roadmap AI';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title}>
|
||||||
|
<GenerateRoadmap
|
||||||
|
roadmapId={roadmap?.id}
|
||||||
|
isAuthenticatedUser={roadmap?.isAuthenticatedUser}
|
||||||
|
client:load
|
||||||
|
/>
|
||||||
|
</BaseLayout>
|
Reference in New Issue
Block a user