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>
fix/ai-slug
Arik Chakma 7 months ago committed by GitHub
parent 6326a80b22
commit 961d398b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 20
      src/api/ai-roadmap.ts
  2. 3
      src/components/ExploreAIRoadmap/AIRoadmapsList.tsx
  3. 1
      src/components/ExploreAIRoadmap/ExploreAIRoadmap.tsx
  4. 92
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  5. 9
      src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
  6. 34
      src/pages/ai/[aiRoadmapSlug].astro

@ -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;
@ -180,13 +179,13 @@ 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

@ -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>
Loading…
Cancel
Save