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 = {}; |
type AIAnnouncementProps = {}; |
||||||
|
|
||||||
export function AIAnnouncement(props: AIAnnouncementProps) { |
export function FeatureAnnouncement(props: AIAnnouncementProps) { |
||||||
return ( |
return ( |
||||||
<a |
<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" |
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"> |
<span className="relative -top-[1px] mr-1 text-xs font-semibold uppercase text-white"> |
||||||
New |
New |
||||||
</span>{' '} |
</span>{' '} |
||||||
<span className={'hidden sm:inline'}>Generate visual roadmaps with AI</span> |
<span className={'hidden sm:inline'}> |
||||||
<span className={'inline text-sm sm:hidden'}>AI Roadmap Generator!</span> |
Explore community made roadmaps |
||||||
|
</span> |
||||||
|
<span className={'inline text-sm sm:hidden'}> |
||||||
|
Community roadmaps explorer! |
||||||
|
</span> |
||||||
</a> |
</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