feat: implement discover custom roadmaps

feat/discover
Arik Chakma 5 months ago
parent 3302c9ab3f
commit b5395cd0c1
  1. 26
      package.json
  2. 1504
      pnpm-lock.yaml
  3. 33
      src/api/roadmap.ts
  4. 20
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  5. 84
      src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
  6. 53
      src/components/DiscoverRoadmaps/EmptyDiscoverRoadmaps.tsx
  7. 43
      src/components/DiscoverRoadmaps/SearchRoadmap.tsx
  8. 29
      src/pages/discover.astro

@ -32,13 +32,13 @@
"@astrojs/react": "^3.6.0",
"@astrojs/sitemap": "^3.1.6",
"@astrojs/tailwind": "^5.1.0",
"@fingerprintjs/fingerprintjs": "^4.4.1",
"@fingerprintjs/fingerprintjs": "^4.4.3",
"@nanostores/react": "^0.7.2",
"@napi-rs/image": "^1.9.2",
"@resvg/resvg-js": "^2.6.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"astro": "^4.11.3",
"astro": "^4.11.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"dom-to-image": "^2.6.0",
@ -46,35 +46,35 @@
"gray-matter": "^4.0.3",
"htm": "^3.1.1",
"image-size": "^1.1.1",
"jose": "^5.6.2",
"jose": "^5.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.399.0",
"nanoid": "^5.0.7",
"nanostores": "^0.10.3",
"node-html-parser": "^6.1.13",
"npm-check-updates": "^16.14.20",
"playwright": "^1.45.0",
"playwright": "^1.45.2",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"react-calendar-heatmap": "^1.9.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.3.1",
"react-tooltip": "^5.27.0",
"react-tooltip": "^5.27.1",
"reactflow": "^11.11.4",
"rehype-external-links": "^3.0.0",
"remark-parse": "^11.0.0",
"roadmap-renderer": "^1.0.6",
"satori": "^0.10.13",
"satori": "^0.10.14",
"satori-html": "^0.3.2",
"sharp": "^0.33.4",
"slugify": "^1.6.6",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.4",
"tailwind-merge": "^2.4.0",
"tailwindcss": "^3.4.6",
"unified": "^11.0.5",
"zustand": "^4.5.4"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.45.2",
"@tailwindcss/typography": "^0.5.13",
"@types/dom-to-image": "^2.6.7",
"@types/js-cookie": "^3.0.6",
@ -84,10 +84,10 @@
"gh-pages": "^6.1.1",
"js-yaml": "^4.1.0",
"markdown-it": "^14.1.0",
"openai": "^4.52.2",
"prettier": "^3.3.2",
"prettier-plugin-astro": "^0.14.0",
"openai": "^4.52.7",
"prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.5",
"tsx": "^4.16.0"
"tsx": "^4.16.2"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,33 @@
import { type APIContext } from 'astro';
import { api } from './api.ts';
import type { RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export type ListShowcaseRoadmapResponse = {
data: Pick<
RoadmapDocument,
| '_id'
| 'title'
| 'description'
| 'slug'
| 'creatorId'
| 'visibility'
| 'createdAt'
| 'topicCount'
>[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function roadmapApi(context: APIContext) {
return {
listShowcaseRoadmap: async function () {
const searchParams = new URLSearchParams(context.url.searchParams);
return api(context).get<ListShowcaseRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`,
searchParams,
);
},
};
}

@ -23,24 +23,36 @@ export const allowedCustomRoadmapType = ['role', 'skill'] as const;
export type AllowedCustomRoadmapType =
(typeof allowedCustomRoadmapType)[number];
export const allowedShowcaseStatus = ['visible', 'hidden'] as const;
export type AllowedShowcaseStatus = (typeof allowedShowcaseStatus)[number];
export interface RoadmapDocument {
_id?: string;
title: string;
description?: string;
slug?: string;
creatorId: string;
aiRoadmapId?: string;
teamId?: string;
isDiscoverable: boolean;
type: AllowedCustomRoadmapType;
topicCount: number;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
isDiscoverable?: boolean;
showcaseStatus?: AllowedShowcaseStatus;
feedbacks?: {
userId: string;
email: string;
feedback: string;
}[];
metadata?: {
originalRoadmapId?: string;
defaultRoadmapId?: string;
};
nodes: any[];
edges: any[];
createdAt: Date;
updatedAt: Date;
canManage: boolean;
isCustomResource: boolean;
}
interface CreateRoadmapModalProps {

@ -0,0 +1,84 @@
import { Shapes } from 'lucide-react';
import type { ListShowcaseRoadmapResponse } from '../../api/roadmap';
import { Pagination } from '../Pagination/Pagination';
import { SearchRoadmap } from './SearchRoadmap';
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps';
type DiscoverRoadmapsProps = {
searchParams: string;
roadmapsResponse: ListShowcaseRoadmapResponse;
};
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) {
const { roadmapsResponse, searchParams: defaultSearchparams } = props;
const roadmaps = roadmapsResponse?.data || [];
const searchParams = new URLSearchParams(defaultSearchparams);
const titleQuery = searchParams.get('q') || '';
return (
<section className="container mx-auto py-3 sm:py-6">
<SearchRoadmap
total={roadmapsResponse?.totalCount || 0}
value={titleQuery}
/>
{roadmaps.length === 0 && <EmptyDiscoverRoadmaps />}
{roadmaps.length > 0 && (
<>
<ul className="mb-4 grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2 lg:grid-cols-3">
{roadmaps.map((roadmap) => {
const roadmapLink = `/r/${roadmap.slug}`;
return (
<li key={roadmap._id} className="h-full">
<a
key={roadmap._id}
href={roadmapLink}
className="flex h-full flex-col rounded-md border transition-colors hover:bg-gray-100"
target={'_blank'}
>
<div className="grow">
<h2 className="mt-2.5 px-2.5 text-base font-medium leading-tight">
{roadmap.title}
</h2>
<p className="my-2.5 px-2.5 text-sm text-gray-500">
{roadmap.description}
</p>
</div>
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<span className="flex items-center gap-1.5 text-xs text-gray-400">
<Shapes size={15} className="inline-block" />
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(roadmap.topicCount)}{' '}
topics
</span>
</div>
</a>
</li>
);
})}
</ul>
<Pagination
currPage={roadmapsResponse?.currPage || 1}
totalPages={roadmapsResponse?.totalPages || 1}
perPage={roadmapsResponse?.perPage || 0}
totalCount={roadmapsResponse?.totalCount || 0}
onPageChange={(page) => {
const newSearchParams = new URLSearchParams();
if (titleQuery) {
newSearchParams.set('q', titleQuery);
}
newSearchParams.set('currPage', page.toString());
window.location.href = `/discover?${newSearchParams.toString()}`;
}}
/>
</>
)}
</section>
);
}

@ -0,0 +1,53 @@
import { Map, Wand2 } from 'lucide-react';
import { useState } from 'react';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
export function EmptyDiscoverRoadmaps() {
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const creatingRoadmapModal = isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => setIsCreatingRoadmap(false)}
onCreated={(roadmap) => {
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${roadmap?._id}`;
}}
/>
);
return (
<>
{creatingRoadmapModal}
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl border px-5 py-3 sm:px-0 sm:py-20">
<Wand2 className="mb-4 h-8 w-8 opacity-10 sm:h-14 sm:w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl">
No Roadmaps Found
</h2>
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm">
Try searching for something else or create a new roadmap.
</p>
<div className="flex flex-col items-center gap-1 sm:flex-row sm:gap-1.5">
<button
className="flex w-full items-center gap-1.5 rounded-md bg-gray-900 px-3 py-1.5 text-xs text-white sm:w-auto sm:text-sm"
type="button"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
<Wand2 className="h-4 w-4" />
Create your Roadmap
</button>
<a
href="/roadmaps"
className="flex w-full items-center gap-1.5 rounded-md bg-yellow-400 px-3 py-1.5 text-xs text-black hover:bg-yellow-500 sm:w-auto sm:text-sm"
>
<Map className="h-4 w-4" />
Visit Official Roadmaps
</a>
</div>
</div>
</>
);
}

@ -0,0 +1,43 @@
import { Search } from 'lucide-react';
type SearchRoadmapProps = {
value: string;
total: number;
};
export function SearchRoadmap(props: SearchRoadmapProps) {
const { total, value: defaultValue } = props;
return (
<div className="relative mb-3 flex w-full items-center gap-3">
<form
className="relative flex w-full max-w-[310px] items-center"
action="/discover"
>
<label
className="absolute left-3 flex h-full items-center text-gray-500"
htmlFor="search"
>
<Search className="h-4 w-4" />
</label>
<input
id="q"
name="q"
type="text"
minLength={3}
placeholder="Type 3 or more characters to search..."
className="w-full rounded-md border border-gray-200 px-3 py-2 pl-9 text-sm transition-colors focus:border-black focus:outline-none"
defaultValue={defaultValue}
/>
</form>
{total > 0 && (
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block">
{Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(total)}{' '}
results found
</p>
)}
</div>
);
}

@ -0,0 +1,29 @@
---
import { roadmapApi } from '../api/roadmap';
import BaseLayout from '../layouts/BaseLayout.astro';
import { DiscoverRoadmaps } from '../components/DiscoverRoadmaps/DiscoverRoadmaps';
export const prerender = false;
const roadmapApiClient = roadmapApi(Astro);
const { error, response: roadmaps } =
await roadmapApiClient.listShowcaseRoadmap();
console.log('-'.repeat(20));
console.log(error);
console.log('-'.repeat(20));
const searchParams = Astro.url.searchParams.toString();
---
<BaseLayout title='Discover Custom Roadmaps'>
{
roadmaps && (
<DiscoverRoadmaps
roadmapsResponse={roadmaps}
searchParams={searchParams}
client:load
/>
)
}
</BaseLayout>
Loading…
Cancel
Save