feat: implement discover custom roadmaps (#6162)
* feat: implement discover custom roadmaps * feat: add error page * wip: roadmap ratings * wip * feat: implement rating * refactor: roadmap discover page * Update UI * fix: search * fix: search query * Update UI for the discover page * Refactor rating logic * Button changes on the custom roadmap page * Refactor feedback modal * Hide rating from custom roadmaps which are not discoverable * feat: rating feedback pagination * fix: remove per page * Update ratings * fix: button height * Update UI for the discover page --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>pull/6282/head
parent
283a88e719
commit
9c3539eb3a
32 changed files with 2024 additions and 1043 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,34 @@ |
||||
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' |
||||
| 'ratings' |
||||
>[]; |
||||
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, |
||||
); |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,90 @@ |
||||
import { useState } from 'react'; |
||||
import { Rating } from '../Rating/Rating'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { CustomRoadmapRatingsModal } from './CustomRoadmapRatingsModal'; |
||||
import { Star } from 'lucide-react'; |
||||
|
||||
type CustomRoadmapRatingsProps = { |
||||
roadmapSlug: string; |
||||
ratings: RoadmapDocument['ratings']; |
||||
canManage?: boolean; |
||||
unseenRatingCount: number; |
||||
}; |
||||
|
||||
export function CustomRoadmapRatings(props: CustomRoadmapRatingsProps) { |
||||
const { ratings, roadmapSlug, canManage, unseenRatingCount } = props; |
||||
const average = ratings?.average || 0; |
||||
|
||||
const totalPeopleWhoRated = Object.keys(ratings?.breakdown || {}).reduce( |
||||
(acc, key) => acc + ratings?.breakdown[key as any], |
||||
0, |
||||
); |
||||
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false); |
||||
|
||||
return ( |
||||
<> |
||||
{isDetailsOpen && ( |
||||
<CustomRoadmapRatingsModal |
||||
roadmapSlug={roadmapSlug} |
||||
onClose={() => { |
||||
setIsDetailsOpen(false); |
||||
}} |
||||
ratings={ratings} |
||||
canManage={canManage} |
||||
/> |
||||
)} |
||||
{average === 0 && ( |
||||
<> |
||||
{!canManage && ( |
||||
<button |
||||
className="flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black" |
||||
onClick={() => { |
||||
setIsDetailsOpen(true); |
||||
}} |
||||
> |
||||
<Star className="size-4 fill-yellow-400 text-yellow-400" /> |
||||
<span className="hidden md:block">Rate this roadmap</span> |
||||
<span className="block md:hidden">Rate</span> |
||||
</button> |
||||
)} |
||||
{canManage && ( |
||||
<span className="flex h-[34px] cursor-default items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium opacity-50"> |
||||
<Star className="size-4 fill-yellow-400 text-yellow-400" /> |
||||
<span className="hidden md:block">No ratings yet</span> |
||||
<span className="block md:hidden">Rate</span> |
||||
</span> |
||||
)} |
||||
</> |
||||
)} |
||||
|
||||
{average > 0 && ( |
||||
<button |
||||
className="relative flex h-[34px] items-center gap-2 rounded-md border border-gray-300 bg-white py-1 pl-2 pr-3 text-sm font-medium hover:border-black" |
||||
onClick={() => { |
||||
setIsDetailsOpen(true); |
||||
}} |
||||
> |
||||
{average.toFixed(1)} |
||||
<span className="hidden lg:block"> |
||||
<Rating |
||||
starSize={16} |
||||
rating={average} |
||||
className={'pointer-events-none gap-px'} |
||||
readOnly |
||||
/> |
||||
</span> |
||||
<span className="lg:hidden"> |
||||
<Star className="size-5 fill-yellow-400 text-yellow-400" /> |
||||
</span> |
||||
({totalPeopleWhoRated}) |
||||
{canManage && unseenRatingCount > 0 && ( |
||||
<span className="absolute right-0 top-0 flex size-4 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full bg-red-500 text-[10px] font-medium leading-none text-white"> |
||||
{unseenRatingCount} |
||||
</span> |
||||
)} |
||||
</button> |
||||
)} |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,58 @@ |
||||
import { useState } from 'react'; |
||||
import { Modal } from '../Modal'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { RateRoadmapForm } from './RateRoadmapForm'; |
||||
import { ListRoadmapRatings } from './ListRoadmapRatings'; |
||||
|
||||
type ActiveTab = 'ratings' | 'feedback'; |
||||
|
||||
type CustomRoadmapRatingsModalProps = { |
||||
onClose: () => void; |
||||
roadmapSlug: string; |
||||
ratings: RoadmapDocument['ratings']; |
||||
canManage?: boolean; |
||||
}; |
||||
|
||||
export function CustomRoadmapRatingsModal( |
||||
props: CustomRoadmapRatingsModalProps, |
||||
) { |
||||
const { onClose, ratings, roadmapSlug, canManage = false } = props; |
||||
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>( |
||||
canManage ? 'feedback' : 'ratings', |
||||
); |
||||
|
||||
const tabs: { |
||||
id: ActiveTab; |
||||
label: string; |
||||
}[] = [ |
||||
{ |
||||
id: 'ratings', |
||||
label: 'Ratings', |
||||
}, |
||||
{ |
||||
id: 'feedback', |
||||
label: 'Feedback', |
||||
}, |
||||
]; |
||||
|
||||
return ( |
||||
<Modal |
||||
onClose={onClose} |
||||
bodyClassName="bg-transparent shadow-none" |
||||
wrapperClassName="h-auto" |
||||
overlayClassName="items-start md:items-center" |
||||
> |
||||
{activeTab === 'ratings' && ( |
||||
<RateRoadmapForm |
||||
ratings={ratings} |
||||
roadmapSlug={roadmapSlug} |
||||
canManage={canManage} |
||||
/> |
||||
)} |
||||
{activeTab === 'feedback' && ( |
||||
<ListRoadmapRatings ratings={ratings} roadmapSlug={roadmapSlug} /> |
||||
)} |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,181 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { Loader2, MessageCircle, ServerCrash } from 'lucide-react'; |
||||
import { Rating } from '../Rating/Rating'; |
||||
import { Spinner } from '../ReactIcons/Spinner.tsx'; |
||||
import { getRelativeTimeString } from '../../lib/date.ts'; |
||||
import { cn } from '../../lib/classname.ts'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal.tsx'; |
||||
import { Pagination } from '../Pagination/Pagination.tsx'; |
||||
|
||||
export interface RoadmapRatingDocument { |
||||
_id?: string; |
||||
roadmapId: string; |
||||
userId: string; |
||||
rating: number; |
||||
feedback?: string; |
||||
|
||||
createdAt: Date; |
||||
updatedAt: Date; |
||||
} |
||||
|
||||
type ListRoadmapRatingsResponse = { |
||||
data: (RoadmapRatingDocument & { |
||||
name: string; |
||||
avatar?: string; |
||||
})[]; |
||||
totalCount: number; |
||||
totalPages: number; |
||||
currPage: number; |
||||
perPage: number; |
||||
}; |
||||
|
||||
type ListRoadmapRatingsProps = { |
||||
roadmapSlug: string; |
||||
ratings: RoadmapDocument['ratings']; |
||||
}; |
||||
|
||||
export function ListRoadmapRatings(props: ListRoadmapRatingsProps) { |
||||
const { roadmapSlug, ratings: ratingSummary } = props; |
||||
|
||||
const totalWhoRated = Object.keys(ratingSummary.breakdown || {}).reduce( |
||||
(acc, key) => acc + ratingSummary.breakdown[key as any], |
||||
0, |
||||
); |
||||
const averageRating = ratingSummary.average; |
||||
|
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [error, setError] = useState(''); |
||||
const [ratingsResponse, setRatingsResponse] = |
||||
useState<ListRoadmapRatingsResponse | null>(null); |
||||
|
||||
const listRoadmapRatings = async (currPage: number = 1) => { |
||||
setIsLoading(true); |
||||
|
||||
const { response, error } = await httpGet<ListRoadmapRatingsResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-roadmap-ratings/${roadmapSlug}`, |
||||
{ |
||||
currPage, |
||||
}, |
||||
); |
||||
|
||||
if (!response || error) { |
||||
setError(error?.message || 'Something went wrong'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
setRatingsResponse(response); |
||||
setError(''); |
||||
setIsLoading(false); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
if (!isLoggedIn()) { |
||||
return; |
||||
} |
||||
|
||||
listRoadmapRatings().then(); |
||||
}, []); |
||||
|
||||
if (error) { |
||||
return ( |
||||
<div className="flex flex-col items-center justify-center bg-white py-10"> |
||||
<ServerCrash className="size-12 text-red-500" /> |
||||
<p className="mt-3 text-lg text-red-500">{error}</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
const ratings = ratingsResponse?.data || []; |
||||
|
||||
return ( |
||||
<div className="relative min-h-[100px] overflow-auto rounded-lg bg-white p-2 md:max-h-[550px]"> |
||||
{isLoading && ( |
||||
<div className="absolute inset-0 flex items-center justify-center"> |
||||
<Spinner isDualRing={false} /> |
||||
</div> |
||||
)} |
||||
|
||||
{!isLoading && ratings.length > 0 && ( |
||||
<div className="relative"> |
||||
<div className="sticky top-1.5 mb-2 flex items-center justify-center gap-1 rounded-lg bg-yellow-50 px-2 py-1.5 text-sm text-yellow-900"> |
||||
<span> |
||||
Rated{' '} |
||||
<span className="font-medium">{averageRating.toFixed(1)}</span> |
||||
</span> |
||||
<Rating starSize={15} rating={averageRating} readOnly /> |
||||
by{' '} |
||||
<span className="font-medium"> |
||||
{totalWhoRated} user{totalWhoRated > 1 && 's'} |
||||
</span> |
||||
</div> |
||||
|
||||
<div className="mb-3 flex flex-col"> |
||||
{ratings.map((rating) => { |
||||
const userAvatar = rating?.avatar |
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${rating.avatar}` |
||||
: '/images/default-avatar.png'; |
||||
|
||||
const isLastRating = |
||||
ratings[ratings.length - 1]._id === rating._id; |
||||
|
||||
return ( |
||||
<div |
||||
key={rating._id} |
||||
className={cn('px-2 py-2.5', { |
||||
'border-b': !isLastRating, |
||||
})} |
||||
> |
||||
<div className="flex items-center justify-between"> |
||||
<div className="flex items-center gap-1"> |
||||
<img |
||||
src={userAvatar} |
||||
alt={rating.name} |
||||
className="h-4 w-4 rounded-full" |
||||
/> |
||||
<span className="text-sm font-medium">{rating.name}</span> |
||||
</div> |
||||
<span className="text-xs text-gray-400"> |
||||
{getRelativeTimeString(rating.createdAt)} |
||||
</span> |
||||
</div> |
||||
|
||||
<div className="mt-2.5"> |
||||
<Rating rating={rating.rating} readOnly /> |
||||
|
||||
{rating.feedback && ( |
||||
<p className="mt-2 text-sm text-gray-500"> |
||||
{rating.feedback} |
||||
</p> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
})} |
||||
</div> |
||||
|
||||
<Pagination |
||||
variant="minimal" |
||||
totalCount={ratingsResponse?.totalCount || 1} |
||||
currPage={ratingsResponse?.currPage || 1} |
||||
totalPages={ratingsResponse?.totalPages || 1} |
||||
perPage={ratingsResponse?.perPage || 1} |
||||
onPageChange={(page) => { |
||||
listRoadmapRatings(page).then(); |
||||
}} |
||||
/> |
||||
</div> |
||||
)} |
||||
|
||||
{!isLoading && ratings.length === 0 && ( |
||||
<div className="flex flex-col items-center justify-center py-10"> |
||||
<MessageCircle className="size-12 text-gray-200" /> |
||||
<p className="mt-3 text-base text-gray-600">No Feedbacks</p> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,273 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; |
||||
import { formatCommaNumber } from '../../lib/number'; |
||||
import { Rating } from '../Rating/Rating'; |
||||
import { httpGet, httpPost } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { isLoggedIn } from '../../lib/jwt'; |
||||
import { Loader2, Star } from 'lucide-react'; |
||||
import { cn } from '../../lib/classname'; |
||||
import { showLoginPopup } from '../../lib/popup'; |
||||
import { Spinner } from '../ReactIcons/Spinner.tsx'; |
||||
|
||||
type GetMyRoadmapRatingResponse = { |
||||
id?: string; |
||||
rating: number; |
||||
feedback?: string; |
||||
}; |
||||
|
||||
type RateRoadmapFormProps = { |
||||
ratings: RoadmapDocument['ratings']; |
||||
roadmapSlug: string; |
||||
canManage?: boolean; |
||||
}; |
||||
|
||||
export function RateRoadmapForm(props: RateRoadmapFormProps) { |
||||
const { ratings, canManage = false, roadmapSlug } = props; |
||||
const { breakdown = {}, average: _average } = ratings || {}; |
||||
const average = _average || 0; |
||||
|
||||
const ratingsKeys = [5, 4, 3, 2, 1]; |
||||
const totalRatings = ratingsKeys.reduce( |
||||
(total, rating) => total + breakdown?.[rating] || 0, |
||||
0, |
||||
); |
||||
|
||||
// if no rating then only show the ratings breakdown if the user can manage the roadmap
|
||||
const showRatingsBreakdown = average > 0 || canManage; |
||||
|
||||
const toast = useToast(); |
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [isSubmitting, setIsSubmitting] = useState(false); |
||||
|
||||
const [isRatingRoadmap, setIsRatingRoadmap] = useState(!showRatingsBreakdown); |
||||
const [userRatingId, setUserRatingId] = useState<string | undefined>(); |
||||
const [userRating, setUserRating] = useState(0); |
||||
const [userFeedback, setUserFeedback] = useState(''); |
||||
|
||||
const loadMyRoadmapRating = async () => { |
||||
// user can't have the rating for their own roadmap
|
||||
if (canManage) { |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
const { response, error } = await httpGet<GetMyRoadmapRatingResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-my-roadmap-rating/${roadmapSlug}`, |
||||
); |
||||
|
||||
if (!response || error) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
setUserRatingId(response?.id); |
||||
setUserRating(response?.rating); |
||||
setUserFeedback(response?.feedback || ''); |
||||
setIsLoading(false); |
||||
}; |
||||
|
||||
const submitMyRoadmapRating = async () => { |
||||
if (userRating <= 0) { |
||||
toast.error('At least give it a star'); |
||||
return; |
||||
} |
||||
|
||||
setIsSubmitting(true); |
||||
const path = userRatingId |
||||
? 'v1-update-custom-roadmap-rating' |
||||
: 'v1-rate-custom-roadmap'; |
||||
const { response, error } = await httpPost<{ |
||||
id: string; |
||||
}>(`${import.meta.env.PUBLIC_API_URL}/${path}/${roadmapSlug}`, { |
||||
rating: userRating, |
||||
feedback: userFeedback, |
||||
}); |
||||
|
||||
if (!response || error) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
setIsSubmitting(false); |
||||
return; |
||||
} |
||||
|
||||
window.location.reload(); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
if (!isLoggedIn() || !roadmapSlug) { |
||||
setIsLoading(false); |
||||
return; |
||||
} |
||||
|
||||
loadMyRoadmapRating().then(); |
||||
}, [roadmapSlug]); |
||||
|
||||
return ( |
||||
<div className="flex flex-col gap-3"> |
||||
{showRatingsBreakdown && !isRatingRoadmap && ( |
||||
<> |
||||
<ul className="flex flex-col gap-1 rounded-lg bg-white p-5"> |
||||
{ratingsKeys.map((rating) => { |
||||
const percentage = |
||||
totalRatings <= 0 |
||||
? 0 |
||||
: ((breakdown?.[rating] || 0) / totalRatings) * 100; |
||||
|
||||
return ( |
||||
<li |
||||
key={`rating-${rating}`} |
||||
className="flex items-center gap-2 text-sm" |
||||
> |
||||
<span className="shrink-0">{rating} star</span> |
||||
<div className="relative h-8 w-full overflow-hidden rounded-md border"> |
||||
<div |
||||
className="h-full bg-yellow-300" |
||||
style={{ width: `${percentage}%` }} |
||||
></div> |
||||
|
||||
{percentage > 0 && ( |
||||
<span className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center justify-center text-xs text-black"> |
||||
{formatCommaNumber(breakdown?.[rating] || 0)} |
||||
</span> |
||||
)} |
||||
</div> |
||||
|
||||
<span className="w-[35px] shrink-0 text-xs text-gray-500"> |
||||
{parseInt(`${percentage}`, 10)}% |
||||
</span> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
</> |
||||
)} |
||||
|
||||
{!canManage && !isRatingRoadmap && ( |
||||
<div className="relative min-h-[100px] rounded-lg bg-white p-4"> |
||||
{isLoading && ( |
||||
<div className="absolute inset-0 flex items-center justify-center"> |
||||
<Spinner isDualRing={false} className="h-5 w-5" /> |
||||
</div> |
||||
)} |
||||
|
||||
{!isLoading && !isRatingRoadmap && !userRatingId && ( |
||||
<> |
||||
<p className="mb-2 text-center text-sm font-medium"> |
||||
Rate and share your thoughts with the roadmap creator. |
||||
</p> |
||||
<button |
||||
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60" |
||||
onClick={() => { |
||||
if (!isLoggedIn()) { |
||||
showLoginPopup(); |
||||
return; |
||||
} |
||||
|
||||
setIsRatingRoadmap(true); |
||||
}} |
||||
disabled={isLoading} |
||||
> |
||||
{isLoading ? ( |
||||
<Loader2 className="size-4 animate-spin" /> |
||||
) : ( |
||||
'Rate Roadmap' |
||||
)} |
||||
</button> |
||||
</> |
||||
)} |
||||
|
||||
{!isLoading && !isRatingRoadmap && userRatingId && ( |
||||
<div> |
||||
<h3 className="mb-2.5 flex items-center justify-between text-base font-semibold"> |
||||
Your Feedback |
||||
<button |
||||
className="ml-2 text-sm font-medium text-blue-500 underline underline-offset-2" |
||||
onClick={() => { |
||||
setIsRatingRoadmap(true); |
||||
}} |
||||
> |
||||
Edit Rating |
||||
</button> |
||||
</h3> |
||||
<div className="flex items-center gap-2"> |
||||
<Rating rating={userRating} starSize={19} readOnly /> ( |
||||
{userRating}) |
||||
</div> |
||||
{userFeedback && <p className="mt-2 text-sm">{userFeedback}</p>} |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
|
||||
{!canManage && isRatingRoadmap && ( |
||||
<div className="rounded-lg bg-white p-5"> |
||||
<h3 className="font-semibold">Rate this roadmap</h3> |
||||
<p className="mt-1 text-sm"> |
||||
Share your thoughts with the roadmap creator. |
||||
</p> |
||||
|
||||
<form |
||||
className="mt-4" |
||||
onSubmit={(e) => { |
||||
e.preventDefault(); |
||||
submitMyRoadmapRating().then(); |
||||
}} |
||||
> |
||||
<Rating |
||||
rating={userRating} |
||||
onRatingChange={(rating) => { |
||||
setUserRating(rating); |
||||
}} |
||||
starSize={32} |
||||
/> |
||||
<div className="mt-3 flex flex-col gap-1"> |
||||
<label |
||||
htmlFor="rating-feedback" |
||||
className="block text-sm font-medium" |
||||
> |
||||
Feedback to Creator{' '} |
||||
<span className="font-normal text-gray-400">(Optional)</span> |
||||
</label> |
||||
<textarea |
||||
id="rating-feedback" |
||||
className="min-h-24 rounded-md border p-2 text-sm outline-none focus:border-gray-500" |
||||
placeholder="Share your thoughts with the roadmap creator" |
||||
value={userFeedback} |
||||
onChange={(e) => { |
||||
setUserFeedback(e.target.value); |
||||
}} |
||||
/> |
||||
</div> |
||||
|
||||
<div className={cn('mt-4 grid grid-cols-2 gap-1')}> |
||||
<button |
||||
className="h-10 w-full rounded-full border p-2.5 text-sm font-medium disabled:opacity-60" |
||||
onClick={() => { |
||||
setIsRatingRoadmap(false); |
||||
}} |
||||
type="button" |
||||
disabled={isSubmitting} |
||||
> |
||||
Cancel |
||||
</button> |
||||
<button |
||||
className="flex h-10 w-full items-center justify-center rounded-full bg-black p-2.5 text-sm font-medium text-white disabled:opacity-60" |
||||
type="submit" |
||||
disabled={isSubmitting} |
||||
> |
||||
{isSubmitting ? ( |
||||
<Loader2 className="size-4 animate-spin" /> |
||||
) : userRatingId ? ( |
||||
'Update Rating' |
||||
) : ( |
||||
'Submit Rating' |
||||
)} |
||||
</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,21 @@ |
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; |
||||
|
||||
type DiscoverErrorProps = { |
||||
message: string; |
||||
}; |
||||
|
||||
export function DiscoverError(props: DiscoverErrorProps) { |
||||
const { message } = props; |
||||
|
||||
return ( |
||||
<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"> |
||||
<ErrorIcon additionalClasses="mb-4 h-8 w-8 sm:h-14 sm:w-14" /> |
||||
<h2 className="mb-1 text-lg font-semibold sm:text-xl"> |
||||
Oops! Something went wrong |
||||
</h2> |
||||
<p className="mb-3 text-balance text-center text-xs text-gray-800 sm:text-sm"> |
||||
{message} |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,77 @@ |
||||
import { ArrowDownWideNarrow, Check, ChevronDown } from 'lucide-react'; |
||||
import { useRef, useState } from 'react'; |
||||
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||
import type { SortByValues } from './DiscoverRoadmaps'; |
||||
|
||||
const sortingLabels: { label: string; value: SortByValues }[] = [ |
||||
{ |
||||
label: 'Newest', |
||||
value: 'createdAt', |
||||
}, |
||||
{ |
||||
label: 'Oldest', |
||||
value: '-createdAt', |
||||
}, |
||||
{ |
||||
label: 'Highest Rated', |
||||
value: 'rating', |
||||
}, |
||||
{ |
||||
label: 'Lowest Rated', |
||||
value: '-rating', |
||||
}, |
||||
]; |
||||
|
||||
type DiscoverRoadmapSortingProps = { |
||||
sortBy: SortByValues; |
||||
onSortChange: (sortBy: SortByValues) => void; |
||||
}; |
||||
|
||||
export function DiscoverRoadmapSorting(props: DiscoverRoadmapSortingProps) { |
||||
const { sortBy, onSortChange } = props; |
||||
|
||||
const [isOpen, setIsOpen] = useState(false); |
||||
const dropdownRef = useRef(null); |
||||
|
||||
const selectedValue = sortingLabels.find((item) => item.value === sortBy); |
||||
|
||||
useOutsideClick(dropdownRef, () => { |
||||
setIsOpen(false); |
||||
}); |
||||
|
||||
return ( |
||||
<div |
||||
className="min-auto relative flex flex-shrink-0 sm:min-w-[140px]" |
||||
ref={dropdownRef} |
||||
> |
||||
<button |
||||
className="py-15 flex w-full items-center justify-between gap-2 rounded-md border px-2 text-sm bg-white" |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
> |
||||
<span>{selectedValue?.label}</span> |
||||
|
||||
<span> |
||||
<ChevronDown className="ml-4 h-3.5 w-3.5" /> |
||||
</span> |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div className="absolute right-0 top-10 z-10 min-w-40 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"> |
||||
{sortingLabels.map((item) => ( |
||||
<button |
||||
key={item.value} |
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100" |
||||
onClick={() => { |
||||
onSortChange(item.value); |
||||
setIsOpen(false); |
||||
}} |
||||
> |
||||
<span>{item.label}</span> |
||||
{item.value === sortBy && <Check className="ml-auto h-4 w-4" />} |
||||
</button> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,271 @@ |
||||
import { Shapes } from 'lucide-react'; |
||||
import type { ListShowcaseRoadmapResponse } from '../../api/roadmap'; |
||||
import { Pagination } from '../Pagination/Pagination'; |
||||
import { SearchRoadmap } from './SearchRoadmap'; |
||||
import { EmptyDiscoverRoadmaps } from './EmptyDiscoverRoadmaps'; |
||||
import { Rating } from '../Rating/Rating'; |
||||
import { useEffect, useState } from 'react'; |
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser'; |
||||
import { LoadingRoadmaps } from '../ExploreAIRoadmap/LoadingRoadmaps'; |
||||
import { httpGet } from '../../lib/http'; |
||||
import { useToast } from '../../hooks/use-toast'; |
||||
import { DiscoverRoadmapSorting } from './DiscoverRoadmapSorting'; |
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx'; |
||||
import { Tooltip } from '../Tooltip.tsx'; |
||||
|
||||
type DiscoverRoadmapsProps = {}; |
||||
|
||||
export type SortByValues = 'rating' | '-rating' | 'createdAt' | '-createdAt'; |
||||
|
||||
type QueryParams = { |
||||
q?: string; |
||||
s?: SortByValues; |
||||
p?: string; |
||||
}; |
||||
|
||||
type PageState = { |
||||
searchTerm: string; |
||||
sortBy: SortByValues; |
||||
currentPage: number; |
||||
}; |
||||
|
||||
export function DiscoverRoadmaps(props: DiscoverRoadmapsProps) { |
||||
const toast = useToast(); |
||||
|
||||
const [pageState, setPageState] = useState<PageState>({ |
||||
searchTerm: '', |
||||
sortBy: 'createdAt', |
||||
currentPage: 0, |
||||
}); |
||||
const [isLoading, setIsLoading] = useState(true); |
||||
const [roadmapsResponse, setRoadmapsResponse] = |
||||
useState<ListShowcaseRoadmapResponse | null>(null); |
||||
|
||||
useEffect(() => { |
||||
const queryParams = getUrlParams() as QueryParams; |
||||
|
||||
setPageState({ |
||||
searchTerm: queryParams.q || '', |
||||
sortBy: queryParams.s || 'createdAt', |
||||
currentPage: +(queryParams.p || '1'), |
||||
}); |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
setIsLoading(true); |
||||
if (!pageState.currentPage) { |
||||
return; |
||||
} |
||||
|
||||
// only set the URL params if the user modified anything
|
||||
if ( |
||||
pageState.currentPage !== 1 || |
||||
pageState.searchTerm !== '' || |
||||
pageState.sortBy !== 'createdAt' |
||||
) { |
||||
setUrlParams({ |
||||
q: pageState.searchTerm, |
||||
s: pageState.sortBy, |
||||
p: String(pageState.currentPage), |
||||
}); |
||||
} else { |
||||
deleteUrlParam('q'); |
||||
deleteUrlParam('s'); |
||||
deleteUrlParam('p'); |
||||
} |
||||
|
||||
loadAIRoadmaps( |
||||
pageState.currentPage, |
||||
pageState.searchTerm, |
||||
pageState.sortBy, |
||||
).finally(() => { |
||||
setIsLoading(false); |
||||
}); |
||||
}, [pageState]); |
||||
|
||||
const loadAIRoadmaps = async ( |
||||
currPage: number = 1, |
||||
searchTerm: string = '', |
||||
sortBy: SortByValues = 'createdAt', |
||||
) => { |
||||
const { response, error } = await httpGet<ListShowcaseRoadmapResponse>( |
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-showcase-roadmap`, |
||||
{ |
||||
currPage, |
||||
...(searchTerm && { searchTerm }), |
||||
...(sortBy && { sortBy }), |
||||
}, |
||||
); |
||||
|
||||
if (error || !response) { |
||||
toast.error(error?.message || 'Something went wrong'); |
||||
return; |
||||
} |
||||
|
||||
setRoadmapsResponse(response); |
||||
}; |
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); |
||||
|
||||
const roadmaps = roadmapsResponse?.data || []; |
||||
|
||||
const loadingIndicator = isLoading && <LoadingRoadmaps />; |
||||
|
||||
return ( |
||||
<> |
||||
{isCreatingRoadmap && ( |
||||
<CreateRoadmapModal |
||||
onClose={() => { |
||||
setIsCreatingRoadmap(false); |
||||
}} |
||||
/> |
||||
)} |
||||
|
||||
<div className="border-b bg-white pt-10 pb-7"> |
||||
<div className="container text-left"> |
||||
<div className="flex flex-col items-start bg-white"> |
||||
<h1 className="mb-1 text-2xl font-bold sm:text-4xl"> |
||||
Community Roadmaps |
||||
</h1> |
||||
<p className="mb-3 text-base text-gray-500"> |
||||
An unvetted, selected list of community-curated roadmaps |
||||
</p> |
||||
<div className="relative"> |
||||
<div className="flex flex-col sm:flex-row items-center gap-1.5"> |
||||
<span className="group relative normal-case"> |
||||
<Tooltip |
||||
position={'bottom-left'} |
||||
additionalClass={ |
||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black' |
||||
} |
||||
> |
||||
Ask us to feature it once you're done! |
||||
</Tooltip> |
||||
<button |
||||
className="rounded-md bg-black px-3.5 py-1.5 text-sm font-medium text-white transition-colors hover:bg-black" |
||||
onClick={() => { |
||||
setIsCreatingRoadmap(true); |
||||
}} |
||||
> |
||||
Create your own roadmap |
||||
</button> |
||||
</span> |
||||
<span className="group relative normal-case"> |
||||
<Tooltip |
||||
position={'bottom-left'} |
||||
additionalClass={ |
||||
'translate-y-0.5 bg-yellow-300 font-normal !text-black' |
||||
} |
||||
> |
||||
Up-to-date and maintained by the official team |
||||
</Tooltip> |
||||
<a |
||||
href="/roadmaps" |
||||
className="inline-block rounded-md bg-gray-300 px-3.5 py-2 text-sm text-black sm:py-1.5 sm:text-base" |
||||
> |
||||
Visit our official roadmaps |
||||
</a> |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="bg-gray-50 py-3"> |
||||
<section className="container mx-auto py-3"> |
||||
<div className="mb-3.5 flex items-stretch justify-between gap-2.5"> |
||||
<SearchRoadmap |
||||
total={roadmapsResponse?.totalCount || 0} |
||||
value={pageState.searchTerm} |
||||
isLoading={isLoading} |
||||
onValueChange={(value) => { |
||||
setPageState({ |
||||
...pageState, |
||||
searchTerm: value, |
||||
currentPage: 1, |
||||
}); |
||||
}} |
||||
/> |
||||
|
||||
<DiscoverRoadmapSorting |
||||
sortBy={pageState.sortBy} |
||||
onSortChange={(sortBy) => { |
||||
setPageState({ |
||||
...pageState, |
||||
sortBy, |
||||
}); |
||||
}} |
||||
/> |
||||
</div> |
||||
|
||||
{loadingIndicator} |
||||
{roadmaps.length === 0 && !isLoading && <EmptyDiscoverRoadmaps />} |
||||
{roadmaps.length > 0 && !isLoading && ( |
||||
<> |
||||
<ul className="mb-4 grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
||||
{roadmaps.map((roadmap) => { |
||||
const roadmapLink = `/r/${roadmap.slug}`; |
||||
const totalRatings = Object.keys( |
||||
roadmap.ratings?.breakdown || [], |
||||
).reduce( |
||||
(acc: number, key: string) => |
||||
acc + roadmap.ratings.breakdown[key as any], |
||||
0, |
||||
); |
||||
return ( |
||||
<li key={roadmap._id} className="h-full min-h-[175px]"> |
||||
<a |
||||
key={roadmap._id} |
||||
href={roadmapLink} |
||||
className="flex h-full flex-col rounded-lg border bg-white p-3.5 transition-colors hover:border-gray-300 hover:bg-gray-50" |
||||
target={'_blank'} |
||||
> |
||||
<div className="grow"> |
||||
<h2 className="text-balance text-base font-bold leading-tight"> |
||||
{roadmap.title} |
||||
</h2> |
||||
<p className="mt-2 text-sm text-gray-500"> |
||||
{roadmap.description} |
||||
</p> |
||||
</div> |
||||
|
||||
<div className="flex items-center justify-between gap-2"> |
||||
<span className="flex items-center gap-1 text-xs text-gray-400"> |
||||
<Shapes size={15} className="inline-block" /> |
||||
{Intl.NumberFormat('en-US', { |
||||
notation: 'compact', |
||||
}).format(roadmap.topicCount)}{' '} |
||||
</span> |
||||
|
||||
<Rating |
||||
rating={roadmap?.ratings?.average || 0} |
||||
readOnly={true} |
||||
starSize={16} |
||||
total={totalRatings} |
||||
/> |
||||
</div> |
||||
</a> |
||||
</li> |
||||
); |
||||
})} |
||||
</ul> |
||||
|
||||
<Pagination |
||||
currPage={roadmapsResponse?.currPage || 1} |
||||
totalPages={roadmapsResponse?.totalPages || 1} |
||||
perPage={roadmapsResponse?.perPage || 0} |
||||
totalCount={roadmapsResponse?.totalCount || 0} |
||||
onPageChange={(page) => { |
||||
setPageState({ |
||||
...pageState, |
||||
currentPage: page, |
||||
}); |
||||
}} |
||||
/> |
||||
</> |
||||
)} |
||||
</section> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -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 bg-white"> |
||||
<Map 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-gray-300 px-3 py-1.5 text-xs text-black hover:bg-gray-400 sm:w-auto sm:text-sm" |
||||
> |
||||
<Map className="h-4 w-4" /> |
||||
Visit Official Roadmaps |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,76 @@ |
||||
import { Search } from 'lucide-react'; |
||||
import { useEffect, useState } from 'react'; |
||||
import { useDebounceValue } from '../../hooks/use-debounce'; |
||||
import { Spinner } from '../ReactIcons/Spinner'; |
||||
|
||||
type SearchRoadmapProps = { |
||||
value: string; |
||||
total: number; |
||||
isLoading: boolean; |
||||
onValueChange: (value: string) => void; |
||||
}; |
||||
|
||||
export function SearchRoadmap(props: SearchRoadmapProps) { |
||||
const { total, value: defaultValue, onValueChange, isLoading } = props; |
||||
|
||||
const [term, setTerm] = useState(defaultValue); |
||||
const debouncedTerm = useDebounceValue(term, 500); |
||||
|
||||
useEffect(() => { |
||||
setTerm(defaultValue); |
||||
}, [defaultValue]); |
||||
|
||||
useEffect(() => { |
||||
if (debouncedTerm && debouncedTerm.length < 3) { |
||||
return; |
||||
} |
||||
|
||||
if (debouncedTerm === defaultValue) { |
||||
return; |
||||
} |
||||
|
||||
onValueChange(debouncedTerm); |
||||
}, [debouncedTerm]); |
||||
|
||||
return ( |
||||
<div className="relative flex w-full items-center gap-3"> |
||||
<form |
||||
className="relative flex w-full max-w-[310px] items-center" |
||||
onSubmit={(e) => { |
||||
e.preventDefault(); |
||||
onValueChange(term); |
||||
}} |
||||
> |
||||
<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" |
||||
value={term} |
||||
onChange={(e) => setTerm(e.target.value)} |
||||
/> |
||||
{isLoading && ( |
||||
<span className="absolute right-3 top-0 flex h-full items-center text-gray-500"> |
||||
<Spinner isDualRing={false} className={`h-3 w-3`} /> |
||||
</span> |
||||
)} |
||||
</form> |
||||
{total > 0 && ( |
||||
<p className="hidden flex-shrink-0 text-sm text-gray-500 sm:block"> |
||||
{Intl.NumberFormat('en-US', { |
||||
notation: 'compact', |
||||
}).format(total)}{' '} |
||||
result{total > 1 ? 's' : ''} found |
||||
</p> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -1,16 +1,20 @@ |
||||
type AIAnnouncementProps = {}; |
||||
|
||||
export function AIAnnouncement(props: AIAnnouncementProps) { |
||||
export function FeatureAnnouncement(props: AIAnnouncementProps) { |
||||
return ( |
||||
<a |
||||
className="rounded-md border border-dashed border-purple-600 px-3 py-1.5 text-purple-400 transition-colors hover:border-purple-400 hover:text-purple-200" |
||||
href="/ai" |
||||
href="/community" |
||||
> |
||||
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white"> |
||||
New |
||||
</span>{' '} |
||||
<span className={'hidden sm:inline'}>Generate visual roadmaps with AI</span> |
||||
<span className={'inline text-sm sm:hidden'}>AI Roadmap Generator!</span> |
||||
<span className={'hidden sm:inline'}> |
||||
Explore community made roadmaps |
||||
</span> |
||||
<span className={'inline text-sm sm:hidden'}> |
||||
Community roadmaps explorer! |
||||
</span> |
||||
</a> |
||||
); |
||||
} |
@ -0,0 +1,8 @@ |
||||
<span class='absolute -right-[11px] top-0'> |
||||
<span class='relative flex h-2 w-2'> |
||||
<span |
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75' |
||||
></span> |
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'></span> |
||||
</span> |
||||
</span> |
@ -0,0 +1,121 @@ |
||||
import { useState } from 'react'; |
||||
import { cn } from '../../lib/classname'; |
||||
|
||||
type RatingProps = { |
||||
rating?: number; |
||||
onRatingChange?: (rating: number) => void; |
||||
starSize?: number; |
||||
readOnly?: boolean; |
||||
className?: string; |
||||
total?: number; |
||||
}; |
||||
|
||||
export function Rating(props: RatingProps) { |
||||
const { |
||||
rating = 0, |
||||
starSize, |
||||
className, |
||||
onRatingChange, |
||||
readOnly = false, |
||||
} = props; |
||||
|
||||
const [stars, setStars] = useState(Number(rating.toFixed(2))); |
||||
const starCount = Math.floor(stars); |
||||
const decimalWidthPercentage = Math.min((stars - starCount) * 100, 100); |
||||
|
||||
return ( |
||||
<div className={cn('flex', className)}> |
||||
{[1, 2, 3, 4, 5].map((counter) => { |
||||
const isActive = counter <= starCount; |
||||
const hasDecimal = starCount + 1 === counter; |
||||
|
||||
return ( |
||||
<RatingStar |
||||
key={`start-${counter}`} |
||||
starSize={starSize} |
||||
widthPercentage={ |
||||
isActive ? 100 : hasDecimal ? decimalWidthPercentage : 0 |
||||
} |
||||
onClick={() => { |
||||
if (readOnly) { |
||||
return; |
||||
} |
||||
|
||||
setStars(counter); |
||||
onRatingChange?.(counter); |
||||
}} |
||||
readOnly={readOnly} |
||||
/> |
||||
); |
||||
})} |
||||
{(props.total || 0) > 0 && ( |
||||
<span className="ml-1.5 text-xs text-gray-400"> |
||||
({Intl.NumberFormat('en-US').format(props.total!)}) |
||||
</span> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
type RatingStarProps = { |
||||
starSize?: number; |
||||
onClick: () => void; |
||||
widthPercentage?: number; |
||||
readOnly: boolean; |
||||
}; |
||||
|
||||
function RatingStar(props: RatingStarProps) { |
||||
const { onClick, widthPercentage = 100, starSize = 20, readOnly } = props; |
||||
|
||||
return ( |
||||
<div |
||||
className="relative block cursor-pointer text-gray-300 disabled:cursor-default aria-disabled:cursor-default" |
||||
style={{ |
||||
width: `${starSize}px`, |
||||
height: `${starSize}px`, |
||||
}} |
||||
onClick={onClick} |
||||
aria-disabled={readOnly} |
||||
role="button" |
||||
> |
||||
<span className="absolute inset-0"> |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 24 24" |
||||
stroke="currentColor" |
||||
strokeWidth="2" |
||||
strokeLinecap="round" |
||||
strokeLinejoin="round" |
||||
className="fill-none" |
||||
style={{ |
||||
width: `${starSize}px`, |
||||
height: `${starSize}px`, |
||||
}} |
||||
> |
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> |
||||
</svg> |
||||
<span |
||||
className="absolute inset-0 overflow-hidden" |
||||
style={{ |
||||
width: `${widthPercentage}%`, |
||||
}} |
||||
> |
||||
<svg |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
viewBox="0 0 24 24" |
||||
strokeWidth="2" |
||||
strokeLinecap="round" |
||||
strokeLinejoin="round" |
||||
className="fill-yellow-400 stroke-yellow-400" |
||||
style={{ |
||||
width: `${starSize}px`, |
||||
height: `${starSize}px`, |
||||
}} |
||||
> |
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> |
||||
</svg> |
||||
</span> |
||||
</span> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,8 @@ |
||||
--- |
||||
import BaseLayout from '../layouts/BaseLayout.astro'; |
||||
import { DiscoverRoadmaps } from '../components/DiscoverRoadmaps/DiscoverRoadmaps'; |
||||
--- |
||||
|
||||
<BaseLayout title='Discover Custom Roadmaps'> |
||||
<DiscoverRoadmaps client:load /> |
||||
</BaseLayout> |
Loading…
Reference in new issue