mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-30 12:40:03 +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 (
|
||||
<ul className="mb-4 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{roadmaps.map((roadmap) => {
|
||||
const roadmapLink = `/ai?id=${roadmap._id}`;
|
||||
const roadmapLink = `/ai/${roadmap.slug}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={roadmap._id}
|
||||
|
@@ -19,6 +19,7 @@ export interface AIRoadmapDocument {
|
||||
term: string;
|
||||
title: string;
|
||||
data: string;
|
||||
slug: string;
|
||||
viewCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
@@ -50,6 +50,7 @@ export type GetAIRoadmapLimitResponse = {
|
||||
};
|
||||
|
||||
const ROADMAP_ID_REGEX = new RegExp('@ROADMAPID:(\\w+)@');
|
||||
const ROADMAP_SLUG_REGEX = new RegExp(/@ROADMAPSLUG:([\w-]+)@/);
|
||||
|
||||
export type RoadmapNodeDetails = {
|
||||
nodeId: string;
|
||||
@@ -87,22 +88,39 @@ type GetAIRoadmapResponse = {
|
||||
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 { id: roadmapId, rc: referralCode } = getUrlParams() as {
|
||||
id: string;
|
||||
const { rc: referralCode } = getUrlParams() as {
|
||||
rc?: string;
|
||||
};
|
||||
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 [isLoadingResults, setIsLoadingResults] = useState(false);
|
||||
const [roadmapTerm, setRoadmapTerm] = useState('');
|
||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||
const [currentRoadmap, setCurrentRoadmap] =
|
||||
useState<GetAIRoadmapResponse | null>(null);
|
||||
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
|
||||
const [selectedNode, setSelectedNode] = useState<RoadmapNodeDetails | null>(
|
||||
null,
|
||||
);
|
||||
@@ -117,7 +135,6 @@ export function GenerateRoadmap() {
|
||||
getOpenAIKey(),
|
||||
);
|
||||
const isKeyOnly = IS_KEY_ONLY_ROADMAP_GENERATION;
|
||||
const isAuthenticatedUser = isLoggedIn();
|
||||
|
||||
const renderRoadmap = async (roadmap: string) => {
|
||||
const { nodes, edges } = generateAIRoadmapFromText(roadmap);
|
||||
@@ -134,6 +151,7 @@ export function GenerateRoadmap() {
|
||||
deleteUrlParam('id');
|
||||
setCurrentRoadmap(null);
|
||||
|
||||
const origin = window.location.origin;
|
||||
const response = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-roadmap`,
|
||||
{
|
||||
@@ -169,13 +187,31 @@ export function GenerateRoadmap() {
|
||||
|
||||
await readAIRoadmapStream(reader, {
|
||||
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:1234@ is the format, we will remove the token and the id
|
||||
// and replace it with a empty string
|
||||
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
|
||||
setUrlParams({ id: roadmapId });
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
const roadmapSlug = result.match(ROADMAP_SLUG_REGEX)?.[1] || '';
|
||||
|
||||
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 =
|
||||
result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
|
||||
setRoadmapTerm(roadmapTitle);
|
||||
@@ -190,7 +226,10 @@ export function GenerateRoadmap() {
|
||||
await renderRoadmap(result);
|
||||
},
|
||||
onStreamEnd: async (result) => {
|
||||
result = result.replace(ROADMAP_ID_REGEX, '');
|
||||
result = result
|
||||
.replace(ROADMAP_ID_REGEX, '')
|
||||
.replace(ROADMAP_SLUG_REGEX, '');
|
||||
|
||||
setGeneratedRoadmapContent(result);
|
||||
loadAIRoadmapLimit().finally(() => {});
|
||||
},
|
||||
@@ -322,7 +361,7 @@ export function GenerateRoadmap() {
|
||||
data,
|
||||
});
|
||||
|
||||
setRoadmapTerm(term);
|
||||
setRoadmapTerm(title || term);
|
||||
setGeneratedRoadmapContent(data);
|
||||
visitAIRoadmap(roadmapId);
|
||||
};
|
||||
@@ -385,12 +424,35 @@ export function GenerateRoadmap() {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasSubmitted(true);
|
||||
loadAIRoadmap(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, [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) {
|
||||
return (
|
||||
<RoadmapSearch
|
||||
@@ -401,7 +463,7 @@ export function GenerateRoadmap() {
|
||||
limitUsed={roadmapLimitUsed}
|
||||
loadAIRoadmapLimit={loadAIRoadmapLimit}
|
||||
isKeyOnly={isKeyOnly}
|
||||
onLoadTerm={(term: string) => {
|
||||
onLoadTerm={(term) => {
|
||||
setRoadmapTerm(term);
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -524,7 +586,7 @@ export function GenerateRoadmap() {
|
||||
)}
|
||||
{!isAuthenticatedUser && (
|
||||
<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}
|
||||
>
|
||||
Login to generate your own roadmaps
|
||||
|
@@ -3,14 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
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 type { RoadmapNodeDetails } from './GenerateRoadmap';
|
||||
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
|
||||
import { readAIRoadmapContentStream } from '../../helper/read-stream';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { OpenAISettings } from './OpenAISettings.tsx';
|
||||
|
||||
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
|
||||
onClose?: () => void;
|
||||
@@ -179,19 +178,19 @@ export function RoadmapTopicDetail(props: RoadmapTopicDetailProps) {
|
||||
)}
|
||||
|
||||
{!isLoggedIn() && (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Contact className="h-14 w-14 text-gray-200 mb-3.5" />
|
||||
<h2 className='font-medium text-xl'>You must be logged in</h2>
|
||||
<p className="text-base text-gray-400">
|
||||
Sign up or login to generate topic content.
|
||||
</p>
|
||||
<button
|
||||
className="mt-3.5 text-base font-medium text-white bg-black px-3 py-2 rounded-md w-full max-w-[300px]"
|
||||
onClick={showLoginPopup}
|
||||
>
|
||||
Sign up / Login
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<Contact className="mb-3.5 h-14 w-14 text-gray-200" />
|
||||
<h2 className="text-xl font-medium">You must be logged in</h2>
|
||||
<p className="text-base text-gray-400">
|
||||
Sign up or login to generate topic content.
|
||||
</p>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
Sign up / Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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