mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-11 11:43:58 +02:00
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>
This commit is contained in:
26
package.json
26
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
1504
pnpm-lock.yaml
generated
1504
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
34
src/api/roadmap.ts
Normal file
34
src/api/roadmap.ts
Normal file
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
@@ -23,24 +23,44 @@ 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[];
|
||||
feedbacks?: {
|
||||
userId: string;
|
||||
email: string;
|
||||
feedback: string;
|
||||
}[];
|
||||
metadata?: {
|
||||
originalRoadmapId?: string;
|
||||
defaultRoadmapId?: string;
|
||||
};
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
|
||||
isDiscoverable?: boolean;
|
||||
showcaseStatus?: AllowedShowcaseStatus;
|
||||
ratings: {
|
||||
average: number;
|
||||
breakdown: {
|
||||
[key: number]: number;
|
||||
};
|
||||
};
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
canManage: boolean;
|
||||
isCustomResource: boolean;
|
||||
}
|
||||
|
||||
interface CreateRoadmapModalProps {
|
||||
|
@@ -18,7 +18,7 @@ export const allowedLinkTypes = [
|
||||
'roadmap.sh',
|
||||
'official',
|
||||
'roadmap',
|
||||
'feed'
|
||||
'feed',
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
@@ -47,6 +47,7 @@ export type GetRoadmapResponse = RoadmapDocument & {
|
||||
canManage: boolean;
|
||||
creator?: CreatorType;
|
||||
team?: CreatorType;
|
||||
unseenRatingCount: number;
|
||||
};
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
|
@@ -1,4 +1,11 @@
|
||||
import { BadgeCheck, MessageCircleHeart, PencilRuler } from 'lucide-react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Heart,
|
||||
HeartHandshake,
|
||||
MessageCircleHeart,
|
||||
PencilRuler,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { useState } from 'react';
|
||||
@@ -17,14 +24,11 @@ export function CustomRoadmapAlert() {
|
||||
/>
|
||||
)}
|
||||
<div className="relative mb-5 mt-0 rounded-md border border-yellow-500 bg-yellow-100 p-2 sm:-mt-6 sm:mb-7 sm:p-2.5">
|
||||
<h2 className="text-base font-semibold text-yellow-800 sm:text-lg">
|
||||
Community Roadmap
|
||||
</h2>
|
||||
<p className="mt-2 mb-2.5 sm:mb-1.5 sm:mt-1 text-sm text-yellow-800 sm:text-base">
|
||||
This is a custom roadmap made by a community member and is not verified by{' '}
|
||||
<span className="font-semibold">roadmap.sh</span>
|
||||
<p className="mb-2.5 mt-2 text-sm text-yellow-800 sm:mb-1.5 sm:mt-1 sm:text-base">
|
||||
This is a custom roadmap made by a community member and is not
|
||||
verified by <span className="font-semibold">roadmap.sh</span>
|
||||
</p>
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row gap-2">
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
@@ -32,20 +36,16 @@ export function CustomRoadmapAlert() {
|
||||
<BadgeCheck className="h-4 w-4 stroke-[2.5]" />
|
||||
Visit Official Roadmaps
|
||||
</a>
|
||||
<span className="font-black text-yellow-700 hidden sm:block">·</span>
|
||||
<button
|
||||
<span className="hidden font-black text-yellow-700 sm:block">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href="/discover"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-700 underline-offset-2 hover:underline"
|
||||
onClick={() => {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
} else {
|
||||
setIsCreatingRoadmap(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PencilRuler className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Your Own Roadmap
|
||||
</button>
|
||||
<HeartHandshake className="h-4 w-4 stroke-[2.5]" />
|
||||
More Community Roadmaps
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<MessageCircleHeart className="absolute bottom-2 right-2 hidden h-12 w-12 text-yellow-500 opacity-50 sm:block" />
|
||||
|
90
src/components/CustomRoadmap/CustomRoadmapRatings.tsx
Normal file
90
src/components/CustomRoadmap/CustomRoadmapRatings.tsx
Normal file
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
58
src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
Normal file
58
src/components/CustomRoadmap/CustomRoadmapRatingsModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
181
src/components/CustomRoadmap/ListRoadmapRatings.tsx
Normal file
181
src/components/CustomRoadmap/ListRoadmapRatings.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
273
src/components/CustomRoadmap/RateRoadmapForm.tsx
Normal file
273
src/components/CustomRoadmap/RateRoadmapForm.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
import { Lock, MoreVertical, PenSquare, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type RoadmapActionButtonProps = {
|
||||
onDelete?: () => void;
|
||||
@@ -32,9 +32,23 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-0 top-full mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md z-[9999]"
|
||||
className="align-right absolute right-0 top-full z-[9999] mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<ul>
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<PenSquare size={14} className="mr-2" />
|
||||
Edit
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
@@ -49,20 +63,6 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
|
@@ -8,11 +8,9 @@ import { httpDelete, httpPut } from '../../lib/http';
|
||||
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { RoadmapActionButton } from './RoadmapActionButton';
|
||||
import { Lock, Shapes } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
|
||||
import { ShareRoadmapButton } from '../ShareRoadmapButton.tsx';
|
||||
import { CustomRoadmapAlert } from './CustomRoadmapAlert.tsx';
|
||||
import { CustomRoadmapRatings } from './CustomRoadmapRatings.tsx';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
@@ -28,10 +26,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
creator,
|
||||
team,
|
||||
visibility,
|
||||
ratings,
|
||||
unseenRatingCount,
|
||||
showcaseStatus,
|
||||
} = useStore(currentRoadmap) || {};
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const [isSharingWithOthers, setIsSharingWithOthers] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
async function deleteResource() {
|
||||
@@ -72,23 +72,6 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const sharingWithOthersModal = isSharingWithOthers && (
|
||||
<Modal
|
||||
onClose={() => setIsSharingWithOthers(false)}
|
||||
wrapperClassName="max-w-lg"
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<ShareSuccess
|
||||
visibility="public"
|
||||
roadmapSlug={roadmapSlug}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
onClose={() => setIsSharingWithOthers(false)}
|
||||
isSharingWithOthers={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
@@ -127,11 +110,12 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex justify-stretch gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
href="/discover"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Roadmaps"
|
||||
>
|
||||
←<span className="hidden sm:inline"> All Roadmaps</span>
|
||||
←
|
||||
<span className="hidden sm:inline"> Discover more</span>
|
||||
</a>
|
||||
|
||||
<ShareRoadmapButton
|
||||
@@ -166,26 +150,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
<span className="hidden sm:inline-block">Edit Roadmap</span>
|
||||
<span className="sm:hidden">Edit</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setIsSharing(true)}
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
Sharing
|
||||
</button>
|
||||
|
||||
<RoadmapActionButton
|
||||
onUpdateSharing={() => setIsSharing(true)}
|
||||
onCustomize={() => {
|
||||
window.location.href = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`;
|
||||
}}
|
||||
onDelete={() => {
|
||||
const confirmation = window.confirm(
|
||||
'Are you sure you want to delete this roadmap?',
|
||||
@@ -201,17 +172,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!$canManageCurrentRoadmap && visibility === 'public' && (
|
||||
<>
|
||||
{sharingWithOthersModal}
|
||||
<button
|
||||
onClick={() => setIsSharingWithOthers(true)}
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
Share with Others
|
||||
</button>
|
||||
</>
|
||||
{((ratings?.average || 0) > 0 || showcaseStatus === 'visible') && (
|
||||
<CustomRoadmapRatings
|
||||
roadmapSlug={roadmapSlug!}
|
||||
ratings={ratings!}
|
||||
canManage={$canManageCurrentRoadmap}
|
||||
unseenRatingCount={unseenRatingCount || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -17,9 +17,8 @@ export function SkeletonRoadmapHeader() {
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[85px]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
|
||||
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[92px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
21
src/components/DiscoverRoadmaps/DiscoverError.tsx
Normal file
21
src/components/DiscoverRoadmaps/DiscoverError.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
77
src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
Normal file
77
src/components/DiscoverRoadmaps/DiscoverRoadmapSorting.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
271
src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
Normal file
271
src/components/DiscoverRoadmaps/DiscoverRoadmaps.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
53
src/components/DiscoverRoadmaps/EmptyDiscoverRoadmaps.tsx
Normal file
53
src/components/DiscoverRoadmaps/EmptyDiscoverRoadmaps.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
76
src/components/DiscoverRoadmaps/SearchRoadmap.tsx
Normal file
76
src/components/DiscoverRoadmaps/SearchRoadmap.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -4,7 +4,7 @@ export function LoadingRoadmaps() {
|
||||
{new Array(21).fill(0).map((_, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="h-[95px] animate-pulse rounded-md border bg-gray-100"
|
||||
className="h-[175px] animate-pulse rounded-md border bg-gray-200"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { AIAnnouncement } from '../AIAnnouncement.tsx';
|
||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
@@ -23,7 +23,7 @@ export function EmptyProgress(props: EmptyProgressProps) {
|
||||
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
|
||||
|
||||
<p className="mt-5">
|
||||
<AIAnnouncement />
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
@@ -7,7 +7,7 @@ import { MapIcon, Users2 } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { AIAnnouncement } from '../AIAnnouncement.tsx';
|
||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@@ -97,7 +97,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-7 mt-2 text-sm">
|
||||
<AIAnnouncement />
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
import { AIAnnouncement } from "../AIAnnouncement";
|
||||
import { FeatureAnnouncement } from "../FeatureAnnouncement";
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -11,7 +11,7 @@ import { AIAnnouncement } from "../AIAnnouncement";
|
||||
id='hero-text'
|
||||
>
|
||||
<p class='-mt-4 mb-7 sm:-mt-10 sm:mb-4'>
|
||||
<AIAnnouncement />
|
||||
<FeatureAnnouncement />
|
||||
</p>
|
||||
|
||||
<h1
|
||||
|
@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import { NavigationDropdown } from '../NavigationDropdown';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
import NewIndicator from './NewIndicator.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
@@ -17,20 +18,20 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='/teams'
|
||||
class='group inline sm:hidden relative !mr-2 text-blue-300 hover:text-white'
|
||||
href='/teams'
|
||||
class='group relative !mr-2 inline text-blue-300 hover:text-white sm:hidden'
|
||||
>
|
||||
Teams
|
||||
|
||||
<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 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>
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
@@ -39,30 +40,27 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/get-started' class='text-gray-400 hover:text-white'>
|
||||
Start Here
|
||||
</a>
|
||||
<a
|
||||
<a
|
||||
href='/teams'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
class='group relative text-gray-400 hover:text-white'
|
||||
>
|
||||
Teams
|
||||
<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>
|
||||
</a>
|
||||
<a href='/ai' class='text-gray-400 hover:text-white'> AI</a>
|
||||
<a
|
||||
href='/ai' class='text-gray-400 hover:text-white'> AI Roadmaps</a>
|
||||
<button
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'
|
||||
href='/community'
|
||||
class='group relative !mr-2 text-blue-300 hover:text-white'
|
||||
>
|
||||
<Icon icon='search' class='h-3 w-3' />
|
||||
<span class='ml-2'>Search</span>
|
||||
</button>
|
||||
Community
|
||||
<NewIndicator />
|
||||
</a>
|
||||
<!--<button-->
|
||||
<!-- data-command-menu-->
|
||||
<!-- class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1.5 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 md:flex'-->
|
||||
<!-->-->
|
||||
<!-- <Icon icon='search' class='h-3 w-3' />-->
|
||||
<!-- <span class='ml-2'>Search</span>-->
|
||||
<!--</button>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
8
src/components/Navigation/NewIndicator.astro
Normal file
8
src/components/Navigation/NewIndicator.astro
Normal file
@@ -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>
|
121
src/components/Rating/Rating.tsx
Normal file
121
src/components/Rating/Rating.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -206,11 +206,11 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
{friends.length === 0 && !isLoading && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Users2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-semibold text-gray-500">
|
||||
<p className="font-medium text-gray-500">
|
||||
You do not have any friends yet. <br />{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
className="underline underline-offset-2 text-sm"
|
||||
href={`/account/friends`}
|
||||
>
|
||||
Invite your friends to share roadmaps with.
|
||||
|
@@ -403,7 +403,7 @@ function DiscoveryCheckbox(props: DiscoveryCheckboxProps) {
|
||||
onChange={(e) => setIsDiscoverable(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500 group-hover:text-gray-700">
|
||||
Include on discovery page (when launched)
|
||||
Include on discovery page
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
@@ -11,7 +11,7 @@ export function TeamsList() {
|
||||
const toast = useToast();
|
||||
async function getAllTeam() {
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`,
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
@@ -36,6 +36,7 @@ export function TeamsList() {
|
||||
Here are the teams you are part of
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="mb-3 flex flex-col gap-1">
|
||||
<li>
|
||||
<a
|
||||
|
@@ -3,6 +3,7 @@ import { clsx } from 'clsx';
|
||||
|
||||
type TooltipProps = {
|
||||
children: ReactNode;
|
||||
additionalClass?: string;
|
||||
position?:
|
||||
| 'right-center'
|
||||
| 'right-top'
|
||||
@@ -19,7 +20,7 @@ type TooltipProps = {
|
||||
};
|
||||
|
||||
export function Tooltip(props: TooltipProps) {
|
||||
const { children, position = 'right-center' } = props;
|
||||
const { children, additionalClass = '', position = 'right-center' } = props;
|
||||
|
||||
let positionClass = '';
|
||||
if (position === 'right-center') {
|
||||
@@ -52,7 +53,8 @@ export function Tooltip(props: TooltipProps) {
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none absolute z-10 block w-max transform rounded-md bg-gray-900 px-2 py-1 text-sm font-medium text-white opacity-0 shadow-sm duration-100 group-hover:opacity-100',
|
||||
positionClass
|
||||
positionClass,
|
||||
additionalClass,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export function getRelativeTimeString(
|
||||
date: string,
|
||||
date: string | Date,
|
||||
isTimed: boolean = false,
|
||||
): string {
|
||||
if (!Intl?.RelativeTimeFormat) {
|
||||
return date;
|
||||
return date.toString();
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('en', {
|
||||
|
8
src/pages/community.astro
Normal file
8
src/pages/community.astro
Normal file
@@ -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>
|
Reference in New Issue
Block a user