Add dashboard redesign (#8189)

* Improve personal dashboard design

* Add projects toggle

* Improve UI for AI roadmaps

* Add builtin roadmaps and best practices

* Collapse and expand

* Move to separate files

* Refactor hero items group

* Collapse expand

* Add expand collapse in hero title

* Add collapse expand of groups

* Style updates

* Collapse expand

* Remove global collapse expand

* Update hero title

* Fix spacing

* Empty screen handling

* Add empty message

* Add profile button

* Add questions listing on dashboard

* Add guides and videos on dashboard

* Responsiveness

* Update messaging
pull/8190/head
Kamran Ahmed 1 week ago committed by GitHub
parent 31a852113f
commit 203bbc6eae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .astro/settings.json
  2. 152
      src/components/Dashboard/DashboardPage.tsx
  3. 10
      src/components/Dashboard/DashboardTabButton.tsx
  4. 566
      src/components/Dashboard/PersonalDashboard.tsx
  5. 47
      src/components/FeaturedGuides.astro
  6. 51
      src/components/FeaturedGuides/FeaturedGuideList.tsx
  7. 57
      src/components/FeaturedGuides/GuideListItem.tsx
  8. 35
      src/components/FeaturedVideos.astro
  9. 39
      src/components/FeaturedVideos/FeaturedVideoList.tsx
  10. 38
      src/components/FeaturedVideos/VideoListItem.tsx
  11. 61
      src/components/GuideListItem.astro
  12. 377
      src/components/HeroSection/FavoriteRoadmaps.tsx
  13. 78
      src/components/HeroSection/HeroItemsGroup.tsx
  14. 52
      src/components/HeroSection/HeroProject.tsx
  15. 74
      src/components/HeroSection/HeroRoadmap.tsx
  16. 264
      src/components/HeroSection/HeroRoadmaps.tsx
  17. 71
      src/components/HeroSection/HeroTitle.tsx
  18. 2
      src/components/RoadCard/RoadmapSelect.tsx
  19. 21
      src/components/Roadmaps/RoadmapsPage.tsx
  20. 13
      src/components/TeamProgress/TeamProgressPage.tsx
  21. 40
      src/components/VideoListItem.astro
  22. 2
      src/lib/question-group.ts
  23. 19
      src/pages/authors/[authorId].astro
  24. 15
      src/pages/dashboard.astro
  25. 8
      src/pages/guides/index.astro
  26. 15
      src/pages/index.astro
  27. 2
      src/pages/videos/index.astro
  28. 18
      src/styles/global.css

@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1738019390029
"lastUpdateCheck": 1739229597159
}
}

@ -1,20 +1,27 @@
import { useStore } from '@nanostores/react';
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { cn } from '../../../editor/utils/classname';
import { useParams } from '../../hooks/use-params';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { httpGet } from '../../lib/http';
import { getUser } from '../../lib/jwt';
import { $teamList } from '../../stores/team';
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
import { DashboardTab } from './DashboardTab';
import { DashboardTabButton } from './DashboardTabButton';
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
import { TeamDashboard } from './TeamDashboard';
import { getUser } from '../../lib/jwt';
import { useParams } from '../../hooks/use-params';
import type { QuestionGroupType } from '../../lib/question-group';
import type { GuideFileType } from '../../lib/guide';
import type { VideoFileType } from '../../lib/video';
type DashboardPageProps = {
builtInRoleRoadmaps?: BuiltInRoadmap[];
builtInSkillRoadmaps?: BuiltInRoadmap[];
builtInBestPractices?: BuiltInRoadmap[];
isTeamPage?: boolean;
questionGroups?: QuestionGroupType[];
guides?: GuideFileType[];
videos?: VideoFileType[];
};
export function DashboardPage(props: DashboardPageProps) {
@ -23,6 +30,9 @@ export function DashboardPage(props: DashboardPageProps) {
builtInBestPractices,
builtInSkillRoadmaps,
isTeamPage = false,
questionGroups,
guides,
videos,
} = props;
const currentUser = getUser();
@ -66,78 +76,80 @@ export function DashboardPage(props: DashboardPageProps) {
: '/images/default-avatar.png';
return (
<div className="min-h-screen bg-gray-50 pb-20 pt-8">
<div className="container">
<div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8">
<DashboardTab
label="Personal"
isActive={!selectedTeamId && !isTeamPage}
href="/dashboard"
avatar={userAvatar}
/>
{isLoading && (
<>
<DashboardTabSkeleton />
<DashboardTabSkeleton />
</>
)}
{!isLoading && (
<>
{teamList.map((team) => {
const { avatar } = team;
const avatarUrl = avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png';
return (
<DashboardTab
key={team._id}
label={team.name}
isActive={team._id === selectedTeamId}
{...(team.status === 'invited'
? {
href: `/respond-invite?i=${team.memberId}`,
}
: {
href: `/team?t=${team._id}`,
})}
avatar={avatarUrl}
/>
);
})}
<DashboardTab
label="+ Create Team"
isActive={false}
href="/team/new"
className="border border-dashed border-gray-300 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-gray-600 hover:text-black"
/>
</>
)}
<>
<div
className={cn('bg-slate-900', {
'striped-loader-slate': isLoading,
})}
>
<div className="bg-slate-800/30 py-5">
<div className="container flex flex-wrap items-center gap-1.5">
<DashboardTabButton
label="Personal"
isActive={!selectedTeamId && !isTeamPage}
href="/dashboard"
avatar={userAvatar}
/>
{!isLoading && (
<>
{teamList.map((team) => {
const { avatar } = team;
const avatarUrl = avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png';
return (
<DashboardTabButton
key={team._id}
label={team.name}
isActive={team._id === selectedTeamId}
{...(team.status === 'invited'
? {
href: `/respond-invite?i=${team.memberId}`,
}
: {
href: `/team?t=${team._id}`,
})}
avatar={avatarUrl}
/>
);
})}
<DashboardTabButton
label="+ Create Team"
isActive={false}
href="/team/new"
className="border border-dashed border-slate-700 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-solid hover:border-slate-700 hover:text-gray-400"
/>
</>
)}
</div>
</div>
</div>
<div className="">
{!selectedTeamId && !isTeamPage && (
<PersonalDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps}
builtInSkillRoadmaps={builtInSkillRoadmaps}
builtInBestPractices={builtInBestPractices}
/>
<div className="bg-slate-900">
<PersonalDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps}
builtInSkillRoadmaps={builtInSkillRoadmaps}
builtInBestPractices={builtInBestPractices}
questionGroups={questionGroups}
guides={guides}
videos={videos}
/>
</div>
)}
{(selectedTeamId || isTeamPage) && (
<TeamDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps!}
builtInSkillRoadmaps={builtInSkillRoadmaps!}
teamId={selectedTeamId!}
/>
<div className="container">
<TeamDashboard
builtInRoleRoadmaps={builtInRoleRoadmaps!}
builtInSkillRoadmaps={builtInSkillRoadmaps!}
teamId={selectedTeamId!}
/>
</div>
)}
</div>
</div>
);
}
function DashboardTabSkeleton() {
return (
<div className="h-[30px] w-[114px] animate-pulse rounded-md border bg-white"></div>
</>
);
}

@ -11,7 +11,7 @@ type DashboardTabProps = {
icon?: ReactNode;
};
export function DashboardTab(props: DashboardTabProps) {
export function DashboardTabButton(props: DashboardTabProps) {
const { isActive, onClick, label, className, href, avatar, icon } = props;
const Slot = href ? 'a' : 'button';
@ -20,8 +20,10 @@ export function DashboardTab(props: DashboardTabProps) {
<Slot
onClick={onClick}
className={cn(
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border bg-white p-1.5 px-2 text-sm leading-none text-gray-600',
isActive ? 'border-gray-500 bg-gray-200 text-gray-900' : '',
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border border-slate-700 bg-slate-800 p-1.5 pl-2 pr-3 text-sm leading-none text-gray-400 transition-colors hover:bg-slate-700',
isActive
? 'border-slate-200 bg-slate-200 text-gray-900 hover:bg-slate-200'
: '',
className,
)}
{...(href ? { href } : {})}
@ -30,7 +32,7 @@ export function DashboardTab(props: DashboardTabProps) {
<img
src={avatar}
alt="avatar"
className="h-4 w-4 mr-0.5 rounded-full object-cover"
className="mr-0.5 h-4 w-4 rounded-full object-cover"
/>
)}
{icon}

@ -1,23 +1,35 @@
import { type JSXElementConstructor, useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { PageType } from '../CommandMenu/CommandMenu';
import { useToast } from '../../hooks/use-toast';
import { getCurrentPeriod } from '../../lib/date';
import { ListDashboardCustomProgress } from './ListDashboardCustomProgress';
import { RecommendedRoadmaps } from './RecommendedRoadmaps';
import { ProgressStack } from './ProgressStack';
import { useStore } from '@nanostores/react';
import { $accountStreak, type StreakResponse } from '../../stores/streak';
import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx';
import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx';
import { BookEmoji } from '../ReactIcons/BookEmoji.tsx';
import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx';
import {
ChartColumn,
CheckCircle,
CheckSquare,
FolderGit2,
Pencil,
SquarePen,
Zap,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import type { AllowedProfileVisibility } from '../../api/user.ts';
import { PencilIcon, type LucideIcon } from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname.ts';
import { httpGet } from '../../lib/http';
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
import { $accountStreak, type StreakResponse } from '../../stores/streak';
import type { PageType } from '../CommandMenu/CommandMenu';
import {
FavoriteRoadmaps,
type AIRoadmapType,
} from '../HeroSection/FavoriteRoadmaps.tsx';
import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import { projectGroups } from '../../pages/index.astro';
import type { QuestionGroupType } from '../../lib/question-group';
import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList';
import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList';
import type { GuideFileType } from '../../lib/guide';
import type { VideoFileType } from '../../lib/video';
type UserDashboardResponse = {
name: string;
@ -28,11 +40,7 @@ type UserDashboardResponse = {
profileVisibility: AllowedProfileVisibility;
progresses: UserProgress[];
projects: ProjectStatusDocument[];
aiRoadmaps: {
id: string;
title: string;
slug: string;
}[];
aiRoadmaps: AIRoadmapType[];
topicDoneToday: number;
};
@ -42,6 +50,7 @@ export type BuiltInRoadmap = {
title: string;
description: string;
isFavorite?: boolean;
isNew?: boolean;
relatedRoadmapIds?: string[];
renderer?: AllowedRoadmapRenderer;
metadata?: Record<string, any>;
@ -51,16 +60,162 @@ type PersonalDashboardProps = {
builtInRoleRoadmaps?: BuiltInRoadmap[];
builtInSkillRoadmaps?: BuiltInRoadmap[];
builtInBestPractices?: BuiltInRoadmap[];
questionGroups?: QuestionGroupType[];
guides?: GuideFileType[];
videos?: VideoFileType[];
};
type DashboardStatItemProps = {
icon: LucideIcon;
iconClassName: string;
value: number;
label: string;
isLoading: boolean;
};
function DashboardStatItem(props: DashboardStatItemProps) {
const { icon: Icon, iconClassName, value, label, isLoading } = props;
return (
<div
className={cn(
'flex items-center gap-1.5 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3',
{
'striped-loader-slate striped-loader-slate-fast text-transparent':
isLoading,
},
)}
>
<Icon
size={16}
className={cn(iconClassName, { 'text-transparent': isLoading })}
/>
<span>
<span className="tabular-nums">{value}</span> {label}
</span>
</div>
);
}
type ProfileButtonProps = {
isLoading: boolean;
name?: string;
username?: string;
avatar?: string;
};
function PersonalProfileButton(props: ProfileButtonProps) {
const { isLoading, name, username, avatar } = props;
if (isLoading || !username) {
return (
<a
href="/account/update-profile"
className={cn(
'flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 font-medium outline-slate-700 hover:bg-slate-800 hover:outline-slate-400',
{
'striped-loader-slate striped-loader-slate-fast text-transparent':
isLoading,
'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20': !isLoading,
},
)}
>
<CheckSquare className="h-4 w-4" strokeWidth={2.5} />
Set up your profile
</a>
);
}
return (
<div className="flex gap-1.5">
<a
href={`/u/${username}`}
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-300 transition-colors hover:bg-slate-800/70"
>
<img
src={avatar}
alt={name || 'Profile'}
className="h-5 w-5 rounded-full ring-1 ring-slate-700"
/>
<span className="font-medium">Visit Profile</span>
</a>
<a
href="/account/update-profile"
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-400 transition-colors hover:bg-slate-800/70 hover:text-slate-300"
title="Edit Profile"
>
<SquarePen className="h-4 w-4" />
</a>
</div>
);
}
type DashboardStatsProps = {
profile: ProfileButtonProps;
accountStreak?: StreakResponse;
topicsDoneToday?: number;
finishedProjectsCount?: number;
isLoading: boolean;
};
function DashboardStats(props: DashboardStatsProps) {
const {
accountStreak,
topicsDoneToday = 0,
finishedProjectsCount = 0,
isLoading,
profile,
} = props;
return (
<div className="container mb-3 flex flex-col gap-4 pb-2 pt-6 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<PersonalProfileButton
isLoading={isLoading}
name={profile.name}
username={profile.username}
avatar={profile.avatar}
/>
<div className="hidden flex-wrap items-center gap-2 md:flex">
<DashboardStatItem
icon={Zap}
iconClassName="text-yellow-500"
value={accountStreak?.count || 0}
label="day streak"
isLoading={isLoading}
/>
<DashboardStatItem
icon={ChartColumn}
iconClassName="text-green-500"
value={topicsDoneToday}
label="learnt today"
isLoading={isLoading}
/>
<DashboardStatItem
icon={FolderGit2}
iconClassName="text-blue-500"
value={finishedProjectsCount}
label="projects finished"
isLoading={isLoading}
/>
</div>
</div>
</div>
);
}
export function PersonalDashboard(props: PersonalDashboardProps) {
const {
builtInRoleRoadmaps = [],
builtInBestPractices = [],
builtInSkillRoadmaps = [],
questionGroups = [],
guides = [],
videos = [],
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [personalDashboardDetails, setPersonalDashboardDetails] =
useState<UserDashboardResponse>();
@ -138,7 +293,9 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
return () => window.removeEventListener('refresh-favorites', loadProgress);
}, []);
const learningRoadmapsToShow = (personalDashboardDetails?.progresses || [])
const learningRoadmapsToShow: UserProgress[] = (
personalDashboardDetails?.progresses || []
)
.filter((progress) => !progress.isCustomResource)
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@ -156,7 +313,10 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
});
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
const customRoadmaps = (personalDashboardDetails?.progresses || [])
const customRoadmaps: UserProgress[] = (
personalDashboardDetails?.progresses || []
)
.filter((progress) => progress.isCustomResource)
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@ -169,43 +329,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png';
const allRoadmapsAndBestPractices = [
...builtInRoleRoadmaps,
...builtInSkillRoadmaps,
...builtInBestPractices,
];
const relatedRoadmapIds = allRoadmapsAndBestPractices
// take the ones that user is learning
.filter((roadmap) =>
learningRoadmapsToShow?.some(
(learningRoadmap) => learningRoadmap.resourceId === roadmap.id,
),
)
.flatMap((roadmap) => roadmap.relatedRoadmapIds)
// remove the ones that user is already learning or has bookmarked
.filter(
(roadmapId) =>
!learningRoadmapsToShow.some((lr) => lr.resourceId === roadmapId),
);
const recommendedRoadmapIds = new Set(
relatedRoadmapIds.length === 0
? [
'frontend',
'backend',
'devops',
'ai-data-scientist',
'full-stack',
'api-design',
]
: relatedRoadmapIds,
);
const recommendedRoadmaps = allRoadmapsAndBestPractices.filter((roadmap) =>
recommendedRoadmapIds.has(roadmap.id),
);
const enrichedProjects = personalDashboardDetails?.projects
.map((project) => {
const projectDetail = projectDetails.find(
@ -232,165 +355,200 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
const { username } = personalDashboardDetails || {};
return (
<section>
{isLoading ? (
<div className="h-7 w-1/4 animate-pulse rounded-lg bg-gray-200"></div>
) : (
<div className="flex flex-col items-start justify-between gap-1 sm:flex-row sm:items-center">
<h2 className="text-lg font-medium">
Hi {name}, good {getCurrentPeriod()}!
</h2>
<a
href="/home"
className="rounded-full bg-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-300 hover:text-black"
>
Visit Homepage
</a>
</div>
)}
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4">
{isLoading ? (
<>
<DashboardCardSkeleton />
<DashboardCardSkeleton />
<DashboardCardSkeleton />
<DashboardCardSkeleton />
</>
) : (
<>
<DashboardCard
imgUrl={avatarLink}
title={name!}
description={
username ? 'View your profile' : 'Setup your profile'
}
href={username ? `/u/${username}` : '/account/update-profile'}
{...(username && {
externalLinkIcon: PencilIcon,
externalLinkHref: '/account/update-profile',
externalLinkText: 'Edit',
})}
className={
!username
? 'border-dashed border-gray-500 bg-gray-100 hover:border-gray-500 hover:bg-gray-200'
: ''
}
/>
<DashboardCard
icon={BookEmoji}
title="Visit Roadmaps"
description="Learn new skills"
href="/roadmaps"
/>
<DashboardCard
icon={ConstructionEmoji}
title="Build Projects"
description="Practice what you learn"
href="/projects"
/>
<DashboardCard
icon={CheckEmoji}
title="Best Practices"
description="Do things the right way"
href="/best-practices"
/>
</>
)}
</div>
<ProgressStack
progresses={learningRoadmapsToShow}
projects={enrichedProjects || []}
<div>
<DashboardStats
profile={{
name,
username,
avatar: avatarLink,
isLoading,
}}
isLoading={isLoading}
accountStreak={accountStreak}
topicDoneToday={personalDashboardDetails?.topicDoneToday || 0}
/>
<ListDashboardCustomProgress
progresses={customRoadmaps}
isLoading={isLoading}
/>
<DashboardAiRoadmaps
roadmaps={aiGeneratedRoadmaps}
isLoading={isLoading}
topicsDoneToday={personalDashboardDetails?.topicDoneToday}
finishedProjectsCount={
enrichedProjects?.filter((p) => p.submittedAt && p.repositoryUrl)
.length
}
/>
<RecommendedRoadmaps
roadmaps={recommendedRoadmaps}
<FavoriteRoadmaps
progress={learningRoadmapsToShow}
customRoadmaps={customRoadmaps}
aiRoadmaps={aiGeneratedRoadmaps}
projects={enrichedProjects || []}
isLoading={isLoading}
/>
</section>
);
}
type DashboardCardProps = {
icon?: JSXElementConstructor<any>;
imgUrl?: string;
title: string;
description: string;
href: string;
externalLinkIcon?: LucideIcon;
externalLinkText?: string;
externalLinkHref?: string;
className?: string;
};
<div className="bg-gradient-to-b from-slate-900 to-black pb-12">
<div className="relative mt-6 border-t border-t-[#1e293c] pt-12">
<div className="container">
<h2
id="role-based-roadmaps"
className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2"
>
Role Based Roadmaps
</h2>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
{builtInRoleRoadmaps.map((roadmap) => {
const roadmapProgress = learningRoadmapsToShow.find(
(lr) => lr.resourceId === roadmap.id,
);
const percentageDone =
(((roadmapProgress?.skipped || 0) +
(roadmapProgress?.done || 0)) /
(roadmapProgress?.total || 1)) *
100;
return (
<HeroRoadmap
key={roadmap.id}
resourceId={roadmap.id}
resourceType="roadmap"
resourceTitle={roadmap.title}
isFavorite={roadmap.isFavorite}
percentageDone={percentageDone}
isNew={roadmap.isNew}
url={`/${roadmap.id}`}
/>
);
})}
</div>
</div>
</div>
function DashboardCard(props: DashboardCardProps) {
const {
icon: Icon,
imgUrl,
title,
description,
href,
externalLinkHref,
externalLinkIcon: ExternalLinkIcon,
externalLinkText,
className,
} = props;
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
<div className="container">
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
Skill Based Roadmaps
</h2>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
{builtInSkillRoadmaps.map((roadmap) => {
const roadmapProgress = learningRoadmapsToShow.find(
(lr) => lr.resourceId === roadmap.id,
);
const percentageDone =
(((roadmapProgress?.skipped || 0) +
(roadmapProgress?.done || 0)) /
(roadmapProgress?.total || 1)) *
100;
return (
<HeroRoadmap
key={roadmap.id}
resourceId={roadmap.id}
resourceType="roadmap"
resourceTitle={roadmap.title}
isFavorite={roadmap.isFavorite}
percentageDone={percentageDone}
isNew={roadmap.isNew}
url={`/${roadmap.id}`}
/>
);
})}
</div>
</div>
</div>
return (
<div className={cn('relative overflow-hidden', className)}>
<a
href={href}
className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50"
>
{Icon && (
<div className="px-4 pb-3 pt-4">
<Icon className="size-6" />
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
<div className="container">
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
Project Ideas
</h2>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
{projectGroups.map((projectGroup) => {
return (
<HeroRoadmap
percentageDone={0}
key={projectGroup.id}
resourceId={projectGroup.id}
resourceType="roadmap"
resourceTitle={projectGroup.title}
url={`/${projectGroup.id}/projects`}
allowFavorite={false}
/>
);
})}
</div>
</div>
)}
</div>
{imgUrl && (
<div className="px-4 pb-1.5 pt-3.5">
<img src={imgUrl} alt={title} className="size-8 rounded-full" />
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
<div className="container">
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
Best Practices
</h2>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
{builtInBestPractices.map((roadmap) => {
const roadmapProgress = learningRoadmapsToShow.find(
(lr) => lr.resourceId === roadmap.id,
);
const percentageDone =
(((roadmapProgress?.skipped || 0) +
(roadmapProgress?.done || 0)) /
(roadmapProgress?.total || 1)) *
100;
return (
<HeroRoadmap
key={roadmap.id}
resourceId={roadmap.id}
resourceType="best-practice"
resourceTitle={roadmap.title}
isFavorite={roadmap.isFavorite}
percentageDone={percentageDone}
isNew={roadmap.isNew}
url={`/best-practices/${roadmap.id}`}
/>
);
})}
</div>
</div>
)}
</div>
<div className="flex grow flex-col justify-center gap-0.5 p-4">
<h3 className="truncate font-medium text-black">{title}</h3>
<p className="text-xs text-black">{description}</p>
<div className="relative mt-12 border-t border-t-[#1e293c] pt-12">
<div className="container">
<h2 className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2">
Questions
</h2>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
{questionGroups.map((questionGroup) => {
return (
<HeroRoadmap
percentageDone={0}
key={questionGroup.id}
resourceId={questionGroup.id}
resourceType="roadmap"
resourceTitle={questionGroup.frontmatter.briefTitle}
url={`/questions/${questionGroup.id}`}
allowFavorite={false}
isNew={questionGroup.frontmatter.isNew}
/>
);
})}
</div>
</div>
</div>
</a>
</div>
{externalLinkHref && (
<a
href={externalLinkHref}
className="absolute right-1 top-1 flex items-center gap-1.5 rounded-md bg-gray-200 p-1 px-2 text-xs text-gray-600 hover:bg-gray-300 hover:text-black"
>
{ExternalLinkIcon && <ExternalLinkIcon className="size-3" />}
{externalLinkText}
</a>
)}
<div className="grid grid-cols-1 gap-5 bg-gray-50 px-4 py-5 sm:gap-16 sm:px-0 sm:py-16">
<FeaturedGuideList
heading="Guides"
guides={guides}
questions={questionGroups
.filter((questionGroup) => questionGroup.frontmatter.authorId)
.slice(0, 7)}
/>
<FeaturedVideoList heading="Videos" videos={videos} />
</div>
</div>
);
}
function DashboardCardSkeleton() {
return (
<div className="h-[128px] animate-pulse rounded-lg border border-gray-300 bg-white"></div>
);
}

@ -1,47 +0,0 @@
---
import type { GuideFileType } from '../lib/guide';
import GuideListItem from './GuideListItem.astro';
import type { QuestionGroupType } from '../lib/question-group';
export interface Props {
heading: string;
guides: GuideFileType[];
questions: QuestionGroupType[];
}
const { heading, guides, questions = [] } = Astro.props;
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
...guides,
...questions,
].sort((a, b) => {
const aDate = new Date(a.frontmatter.date as string);
const bDate = new Date(b.frontmatter.date as string);
return bDate.getTime() - aDate.getTime();
});
---
<div class='container'>
<h2 class='block text-2xl font-bold sm:text-3xl'>{heading}</h2>
<div class='mt-3 sm:my-5'>
{sortedGuides.map((guide) => <GuideListItem guide={guide} />)}
</div>
<a
href='/guides'
class='hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline'
>
View All Guides &rarr;
</a>
<div class='mt-3 block sm:hidden'>
<a
href='/guides'
class='font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50'
>
View All Guides &nbsp;&rarr;
</a>
</div>
</div>

@ -0,0 +1,51 @@
import type { GuideFileType } from '../../lib/guide';
import type { QuestionGroupType } from '../../lib/question-group';
import { GuideListItem } from './GuideListItem';
export interface FeaturedGuidesProps {
heading: string;
guides: GuideFileType[];
questions: QuestionGroupType[];
}
export function FeaturedGuideList(props: FeaturedGuidesProps) {
const { heading, guides, questions = [] } = props;
const sortedGuides: (QuestionGroupType | GuideFileType)[] = [
...guides,
...questions,
].sort((a, b) => {
const aDate = new Date(a.frontmatter.date as string);
const bDate = new Date(b.frontmatter.date as string);
return bDate.getTime() - aDate.getTime();
});
return (
<div className="container">
<h2 className="block text-2xl font-bold sm:text-3xl">{heading}</h2>
<div className="mt-3 sm:my-5">
{sortedGuides.map((guide) => (
<GuideListItem key={guide.id} guide={guide} />
))}
</div>
<a
href="/guides"
className="hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline"
>
View All Guides &rarr;
</a>
<div className="mt-3 block sm:hidden">
<a
href="/guides"
className="font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50"
>
View All Guides &nbsp;&rarr;
</a>
</div>
</div>
);
}

@ -0,0 +1,57 @@
import type { GuideFileType, GuideFrontmatter } from '../../lib/guide';
import { type QuestionGroupType } from '../../lib/question-group';
export interface GuideListItemProps {
guide: GuideFileType | QuestionGroupType;
}
function isQuestionGroupType(
guide: GuideFileType | QuestionGroupType,
): guide is QuestionGroupType {
return (guide as QuestionGroupType).questions !== undefined;
}
export function GuideListItem(props: GuideListItemProps) {
const { guide } = props;
const { frontmatter, id } = guide;
let pageUrl = '';
let guideType = '';
if (isQuestionGroupType(guide)) {
pageUrl = `/questions/${id}`;
guideType = 'Questions';
} else {
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
guideType = (frontmatter as GuideFrontmatter).type;
}
return (
<a
className="text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600"
href={pageUrl}
>
<span className="text-sm transition-transform group-hover:translate-x-2 md:text-base">
{frontmatter.title}
{frontmatter.isNew && (
<span className="ml-2.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900">
New
<span className="hidden sm:inline">
&nbsp;&middot;&nbsp;
{new Date(frontmatter.date || '').toLocaleString('default', {
month: 'long',
})}
</span>
</span>
)}
</span>
<span className="hidden text-xs capitalize text-gray-500 sm:block">
{guideType}
</span>
<span className="block text-xs text-gray-400 sm:hidden"> &raquo;</span>
</a>
);
}

@ -1,35 +0,0 @@
---
import type { VideoFileType } from '../lib/video';
import VideoListItem from './VideoListItem.astro';
export interface Props {
heading: string;
videos: VideoFileType[];
}
const { heading, videos } = Astro.props;
---
<div class='container'>
<h2 class='text-2xl sm:text-3xl font-bold block'>{heading}</h2>
<div class='mt-3 sm:my-5'>
{videos.map((video) => <VideoListItem video={video} />)}
</div>
<a
href='/videos'
class='hidden sm:inline transition-colors py-2 px-3 text-xs font-medium rounded-full bg-gradient-to-r from-slate-600 to-black hover:from-blue-600 hover:to-blue-800 text-white'
>
View All Videos &rarr;
</a>
<div class='block sm:hidden mt-3'>
<a
href='/videos'
class='text-sm font-regular block p-2 border border-black text-black rounded-md text-center hover:bg-black hover:text-gray-50'
>
View All Videos &nbsp;&rarr;
</a>
</div>
</div>

@ -0,0 +1,39 @@
import type { VideoFileType } from '../../lib/video';
import { VideoListItem } from './VideoListItem';
export interface FeaturedVideoListProps {
heading: string;
videos: VideoFileType[];
}
export function FeaturedVideoList(props: FeaturedVideoListProps) {
const { heading, videos } = props;
return (
<div className="container">
<h2 className="block text-2xl font-bold sm:text-3xl">{heading}</h2>
<div className="mt-3 sm:my-5">
{videos.map((video) => (
<VideoListItem key={video.id} video={video} />
))}
</div>
<a
href="/videos"
className="hidden rounded-full bg-gradient-to-r from-slate-600 to-black px-3 py-2 text-xs font-medium text-white transition-colors hover:from-blue-600 hover:to-blue-800 sm:inline"
>
View All Videos &rarr;
</a>
<div className="mt-3 block sm:hidden">
<a
href="/videos"
className="font-regular block rounded-md border border-black p-2 text-center text-sm text-black hover:bg-black hover:text-gray-50"
>
View All Videos &nbsp;&rarr;
</a>
</div>
</div>
);
}

@ -0,0 +1,38 @@
import type { VideoFileType } from '../../lib/video';
export interface VideoListItemProps {
video: VideoFileType;
}
export function VideoListItem(props: VideoListItemProps) {
const { video } = props;
const { frontmatter, id } = video;
return (
<a
className="block no-underline py-2 group text-md items-center text-gray-600 hover:text-blue-600 flex justify-between border-b"
href={`/videos/${id}`}
>
<span className="group-hover:translate-x-2 transition-transform">
{frontmatter.title}
{frontmatter.isNew && (
<span className="bg-green-300 text-green-900 text-xs font-medium px-1.5 py-0.5 rounded-sm uppercase ml-1.5">
New
<span className="hidden sm:inline">
&middot;
{new Date(frontmatter.date).toLocaleString('default', {
month: 'long',
})}
</span>
</span>
)}
</span>
<span className="capitalize text-gray-500 text-xs hidden sm:block">
{frontmatter.duration}
</span>
<span className="text-gray-400 text-xs block sm:hidden"> &raquo;</span>
</a>
);
}

@ -1,61 +0,0 @@
---
import type { GuideFileType, GuideFrontmatter } from '../lib/guide';
import { type QuestionGroupType } from '../lib/question-group';
export interface Props {
guide: GuideFileType | QuestionGroupType;
}
function isQuestionGroupType(
guide: GuideFileType | QuestionGroupType,
): guide is QuestionGroupType {
return (guide as QuestionGroupType).questions !== undefined;
}
const { guide } = Astro.props;
const { frontmatter, id } = guide;
let pageUrl = '';
let guideType = '';
if (isQuestionGroupType(guide)) {
pageUrl = `/questions/${id}`;
guideType = 'Questions';
} else {
const excludedBySlug = (frontmatter as GuideFrontmatter).excludedBySlug;
pageUrl = excludedBySlug ? excludedBySlug : `/guides/${id}`;
guideType = (frontmatter as GuideFrontmatter).type;
}
---
<a
class:list={[
'text-md group block flex items-center justify-between border-b py-2 text-gray-600 no-underline hover:text-blue-600',
]}
href={pageUrl}
>
<span
class='text-sm transition-transform group-hover:translate-x-2 md:text-base'
>
{frontmatter.title}
{
frontmatter.isNew && (
<span class='ml-1.5 rounded-sm bg-green-300 px-1.5 py-0.5 text-xs font-medium uppercase text-green-900'>
New
<span class='hidden sm:inline'>
&middot;
{new Date(frontmatter.date || '').toLocaleString('default', {
month: 'long',
})}
</span>
</span>
)
}
</span>
<span class='hidden text-xs capitalize text-gray-500 sm:block'>
{guideType}
</span>
<span class='block text-xs text-gray-400 sm:hidden'> &raquo;</span>
</a>

@ -1,164 +1,229 @@
import { useEffect, useState } from 'react';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
import { isLoggedIn } from '../../lib/jwt';
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
export type UserProgressResponse = {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
resourceTitle: string;
isFavorite: boolean;
done: number;
learning: number;
skipped: number;
total: number;
updatedAt: Date;
isCustomResource: boolean;
roadmapSlug?: string;
team?: {
name: string;
id: string;
role: AllowedMemberRoles;
};
}[];
function renderProgress(progressList: UserProgressResponse) {
progressList.forEach((progress) => {
const href =
progress.resourceType === 'best-practice'
? `/best-practices/${progress.resourceId}`
: `/${progress.resourceId}`;
const element = document.querySelector(`a[href="${href}"]`);
if (!element) {
return;
}
window.dispatchEvent(
new CustomEvent('mark-favorite', {
detail: {
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isFavorite: progress.isFavorite,
},
}),
);
const totalDone = progress.done + progress.skipped;
const percentageDone = (totalDone / progress.total) * 100;
const progressBar: HTMLElement | null =
element.querySelector('[data-progress]');
if (progressBar) {
progressBar.style.width = `${percentageDone}%`;
}
});
}
type ProgressResponse = UserProgressResponse;
export function FavoriteRoadmaps() {
const isAuthenticated = isLoggedIn();
if (!isAuthenticated) {
return null;
}
const [isPreparing, setIsPreparing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState<ProgressResponse>([]);
const [containerOpacity, setContainerOpacity] = useState(0);
function showProgressContainer() {
const heroEl = document.getElementById('hero-text')!;
if (!heroEl) {
return;
}
heroEl.classList.add('opacity-0');
setTimeout(() => {
heroEl.parentElement?.removeChild(heroEl);
setIsPreparing(false);
setTimeout(() => {
setContainerOpacity(100);
}, 50);
}, 0);
}
async function loadProgress() {
setIsLoading(true);
const { response: progressList, error } = await httpGet<ProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`,
);
if (error || !progressList) {
return;
}
setProgress(progressList);
setIsLoading(false);
showProgressContainer();
// render progress on featured items
renderProgress(progressList);
}
useEffect(() => {
loadProgress().finally(() => {
setIsLoading(false);
});
}, []);
useEffect(() => {
window.addEventListener('refresh-favorites', loadProgress);
return () => window.removeEventListener('refresh-favorites', loadProgress);
}, []);
if (isPreparing) {
return null;
}
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter(
(p) => p.isCustomResource && !p.team?.name,
import {
FolderKanban,
MapIcon,
Plus,
Sparkle,
Eye,
EyeOff,
Square,
SquareCheckBig,
} from 'lucide-react';
import { useState } from 'react';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
import type { UserProgress } from '../TeamProgress/TeamProgressPage.tsx';
import { HeroProject } from './HeroProject';
import { HeroRoadmap } from './HeroRoadmap';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx';
import { HeroItemsGroup } from './HeroItemsGroup';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export type AIRoadmapType = {
id: string;
title: string;
slug: string;
};
type FavoriteRoadmapsProps = {
progress: UserProgress[];
projects: (ProjectStatusDocument & {
title: string;
})[];
customRoadmaps: UserProgress[];
aiRoadmaps: AIRoadmapType[];
isLoading: boolean;
};
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props;
const [showCompleted, setShowCompleted] = useState(false);
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false);
const completedProjects = projects.filter(
(project) => project.submittedAt && project.repositoryUrl,
);
const inProgressProjects = projects.filter(
(project) => !project.submittedAt || !project.repositoryUrl,
);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
const teamRoadmaps: HeroTeamRoadmaps = progress
?.filter((p) => p.isCustomResource && p.team?.name)
.reduce((acc: HeroTeamRoadmaps, curr) => {
const currTeam = curr.team!;
if (!acc[currTeam.name]) {
acc[currTeam.name] = [];
}
acc[currTeam.name].push(curr);
return acc;
}, {});
const projectsToShow = [
...inProgressProjects,
...(showCompleted ? completedProjects : []),
];
return (
<div
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
>
<div
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
<div className="flex flex-col">
{isCreatingCustomRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingCustomRoadmap(false);
}}
/>
)}
<HeroItemsGroup
icon={<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />}
isLoading={isLoading}
title="Your progress and bookmarks"
isEmpty={!isLoading && progress.length === 0}
emptyTitle={
<>
No bookmars found
<a
href="#role-based-roadmaps"
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
>
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
Bookmark a roadmap
</a>
</>
}
>
{progress.map((resource) => (
<HeroRoadmap
key={`${resource.resourceType}-${resource.resourceId}`}
resourceId={resource.resourceId}
resourceType={resource.resourceType}
resourceTitle={resource.resourceTitle}
isFavorite={resource.isFavorite}
percentageDone={
((resource.skipped + resource.done) / resource.total) * 100
}
url={
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`
}
/>
))}
</HeroItemsGroup>
<HeroItemsGroup
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
isLoading={isLoading}
title="Your custom roadmaps"
isEmpty={!isLoading && customRoadmaps.length === 0}
emptyTitle={
<>
No custom roadmaps found
<button
onClick={() => {
setIsCreatingCustomRoadmap(true);
}}
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
>
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
Create custom roadmap
</button>
</>
}
>
{customRoadmaps.map((customRoadmap) => (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r/${customRoadmap?.roadmapSlug}`}
allowFavorite={false}
/>
))}
<CreateRoadmapButton />
</HeroItemsGroup>
<HeroItemsGroup
icon={<Sparkle className="mr-1.5 h-[14px] w-[14px]" />}
isLoading={isLoading}
title="Your AI roadmaps"
isEmpty={!isLoading && aiRoadmaps.length === 0}
emptyTitle={
<>
No AI roadmaps found
<a
href="/ai"
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
>
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
Generate AI roadmap
</a>
</>
}
>
{aiRoadmaps.map((aiRoadmap) => (
<HeroRoadmap
key={aiRoadmap.id}
resourceId={aiRoadmap.id}
resourceType={'roadmap'}
resourceTitle={aiRoadmap.title}
url={`/ai/${aiRoadmap.slug}`}
percentageDone={0}
allowFavorite={false}
isTrackable={false}
/>
))}
<a
href="/ai"
className={
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300'
}
>
<Plus size={16} />
Generate New
</a>
</HeroItemsGroup>
<HeroItemsGroup
icon={<FolderKanban className="mr-1.5 h-[14px] w-[14px]" />}
isLoading={isLoading}
title="Your active projects"
isEmpty={!isLoading && projectsToShow.length === 0}
emptyTitle={
<>
No active projects found
<a
href="/projects"
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
>
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
Start a new project
</a>
</>
}
rightContent={
completedProjects.length > 0 && (
<button
onClick={() => setShowCompleted(!showCompleted)}
className="hidden items-center gap-2 rounded-md text-xs text-slate-400 hover:text-slate-300 sm:flex"
>
{showCompleted ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{completedProjects.length} Completed
</button>
)
}
className="border-b-0"
>
<div className="container min-h-full">
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
teamRoadmaps={teamRoadmaps}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
)}
</div>
</div>
{projectsToShow.map((project) => (
<HeroProject key={project._id} project={project} />
))}
<a
href="/projects"
className="flex min-h-[80px] items-center justify-center gap-2 rounded-md border border-dashed border-slate-800 p-4 text-sm text-slate-400 hover:border-slate-600 hover:bg-slate-900/50 hover:text-slate-300"
>
<Plus size={16} />
Start a new project
</a>
</HeroItemsGroup>
</div>
);
}

@ -0,0 +1,78 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '../../lib/classname';
import { HeroTitle } from './HeroTitle';
type HeroItemsGroupProps = {
icon: any;
isLoading?: boolean;
isEmpty?: boolean;
emptyTitle?: ReactNode;
title: string | ReactNode;
rightContent?: ReactNode;
children?: ReactNode;
className?: string;
};
export function HeroItemsGroup(props: HeroItemsGroupProps) {
const {
icon,
isLoading = false,
isEmpty = false,
emptyTitle,
title,
rightContent,
children,
className,
} = props;
const storageKey = `hero-group-${title}-collapsed`;
const [isCollapsed, setIsCollapsed] = useState(true);
function isCollapsedByStorage() {
const stored = localStorage.getItem(storageKey);
return stored === 'true';
}
useEffect(() => {
setIsCollapsed(isCollapsedByStorage());
}, [isLoading]);
const isLoadingOrCollapsedOrEmpty = isLoading || isCollapsed || isEmpty;
return (
<div
className={cn(
'border-b border-gray-800/50',
{
'py-4': !isLoadingOrCollapsedOrEmpty,
'py-4 ': isLoadingOrCollapsedOrEmpty,
'opacity-50 transition-opacity hover:opacity-100':
isCollapsed && !isLoading,
},
className,
)}
>
<div className="container">
<HeroTitle
icon={icon}
isLoading={isLoading}
isEmpty={isEmpty}
emptyTitle={emptyTitle}
title={title}
rightContent={rightContent}
isCollapsed={isCollapsed}
onToggleCollapse={() => {
setIsCollapsed(!isCollapsed);
localStorage.setItem(storageKey, (!isCollapsed).toString());
}}
/>
{!isLoadingOrCollapsedOrEmpty && (
<div className="mt-4 grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3">
{children}
</div>
)}
</div>
</div>
);
}

@ -0,0 +1,52 @@
import { ThumbsUp } from 'lucide-react';
import { cn } from '../../lib/classname.ts';
import { getRelativeTimeString } from '../../lib/date';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
type HeroProjectProps = {
project: ProjectStatusDocument & {
title: string;
};
};
export function HeroProject({ project }: HeroProjectProps) {
return (
<a
href={`/projects/${project.projectId}`}
className="group relative flex flex-col justify-between gap-2 rounded-md border border-slate-800 bg-slate-900 p-3.5 hover:border-slate-600"
>
<div className="relative z-10 flex items-start justify-between gap-2">
<h3 className="truncate font-medium text-slate-300 group-hover:text-slate-100">
{project.title}
</h3>
<span
className={cn(
'absolute -right-2 -top-2 flex flex-shrink-0 items-center gap-1 rounded-full text-xs uppercase tracking-wide',
{
'text-green-600/50': project.submittedAt && project.repositoryUrl,
'text-yellow-600': !project.submittedAt || !project.repositoryUrl,
},
)}
>
{project.submittedAt && project.repositoryUrl ? 'Done' : ''}
</span>
</div>
<div className="relative z-10 flex items-center gap-2 text-xs text-slate-400">
{project.submittedAt && project.repositoryUrl && (
<span className="flex items-center gap-1">
<ThumbsUp className="h-3 w-3" />
{project.upvotes}
</span>
)}
{project.startedAt && (
<span>Started {getRelativeTimeString(project.startedAt)}</span>
)}
</div>
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-slate-800/50 via-transparent to-transparent" />
{project.submittedAt && project.repositoryUrl && (
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-green-950/20 via-transparent to-transparent" />
)}
</a>
);
}

@ -0,0 +1,74 @@
import { cn } from '../../lib/classname.ts';
import type { ResourceType } from '../../lib/resource-progress.ts';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite.tsx';
type ProgressRoadmapProps = {
url: string;
percentageDone: number;
allowFavorite?: boolean;
resourceId: string;
resourceType: ResourceType;
resourceTitle: string;
isFavorite?: boolean;
isTrackable?: boolean;
isNew?: boolean;
};
export function HeroRoadmap(props: ProgressRoadmapProps) {
const {
url,
percentageDone,
resourceType,
resourceId,
resourceTitle,
isFavorite,
allowFavorite = true,
isTrackable = true,
isNew = false,
} = props;
return (
<a
href={url}
className={cn(
'relative flex flex-col overflow-hidden rounded-md border p-3 text-sm text-slate-400 hover:text-slate-300',
{
'border-slate-800 bg-slate-900 hover:border-slate-600': isTrackable,
'border-slate-700/50 bg-slate-800/50 hover:border-slate-600/70':
!isTrackable,
},
)}
>
<span title={resourceTitle} className="relative z-20 truncate">
{resourceTitle}
</span>
{isTrackable && (
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
)}
{allowFavorite && (
<MarkFavorite
resourceId={resourceId}
resourceType={resourceType}
favorite={isFavorite}
/>
)}
{isNew && (
<span className="absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300">
<span className="mr-1.5 flex h-2 w-2">
<span className="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-purple-500" />
</span>
New
</span>
)}
</a>
);
}

@ -1,264 +0,0 @@
import type { UserProgressResponse } from './FavoriteRoadmaps';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
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 { FeatureAnnouncement } from '../FeatureAnnouncement.tsx';
type ProgressRoadmapProps = {
url: string;
percentageDone: number;
allowFavorite?: boolean;
resourceId: string;
resourceType: ResourceType;
resourceTitle: string;
isFavorite?: boolean;
};
function HeroRoadmap(props: ProgressRoadmapProps) {
const {
url,
percentageDone,
resourceType,
resourceId,
resourceTitle,
isFavorite,
allowFavorite = true,
} = props;
return (
<a
href={url}
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
>
<span className="relative z-20">{resourceTitle}</span>
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
{allowFavorite && (
<MarkFavorite
resourceId={resourceId}
resourceType={resourceType}
favorite={isFavorite}
/>
)}
</a>
);
}
type ProgressTitleProps = {
icon: any;
isLoading?: boolean;
title: string | ReactNode;
};
export function HeroTitle(props: ProgressTitleProps) {
const { isLoading = false, title, icon } = props;
return (
<p className="mb-4 flex items-center text-sm text-gray-400">
{!isLoading && icon}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
{title}
</p>
);
}
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
type ProgressListProps = {
progress: UserProgressResponse;
customRoadmaps: UserProgressResponse;
teamRoadmaps?: HeroTeamRoadmaps;
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const {
teamRoadmaps = {},
progress,
isLoading = false,
customRoadmaps,
} = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>();
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-7 mt-2 text-sm">
<FeatureAnnouncement />
</p>
{isCreatingRoadmap && (
<CreateRoadmapModal
teamId={creatingRoadmapTeamId}
onClose={() => {
setIsCreatingRoadmap(false);
setCreatingRoadmapTeamId(undefined);
}}
/>
)}
{
<HeroTitle
icon={
(<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />) as any
}
isLoading={isLoading}
title="Your progress and favorite roadmaps."
/>
}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => (
<HeroRoadmap
key={`${resource.resourceType}-${resource.resourceId}`}
resourceId={resource.resourceId}
resourceType={resource.resourceType}
resourceTitle={resource.resourceTitle}
isFavorite={resource.isFavorite}
percentageDone={
((resource.skipped + resource.done) / resource.total) * 100
}
url={
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`
}
/>
))}
</div>
<div className="mt-5">
{
<HeroTitle
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
title="Your custom roadmaps"
/>
}
{customRoadmaps.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
You haven't created any custom roadmaps yet.{' '}
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => setIsCreatingRoadmap(true)}
>
Create one!
</button>
</p>
)}
{customRoadmaps.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((customRoadmap) => {
return (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r/${customRoadmap?.roadmapSlug}`}
allowFavorite={false}
/>
);
})}
<CreateRoadmapButton />
</div>
)}
</div>
{Object.keys(teamRoadmaps).map((teamName) => {
const currentTeam: UserProgressResponse[0]['team'] =
teamRoadmaps?.[teamName]?.[0]?.team;
const roadmapsList = teamRoadmaps[teamName].filter(
(roadmap) => !!roadmap.resourceTitle,
);
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!);
return (
<div className="mt-5" key={teamName}>
{
<HeroTitle
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />}
title={
<>
Team{' '}
<a
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
href={`/team/activity?t=${currentTeam?.id}`}
>
{teamName}
</a>
Roadmaps
</>
}
/>
}
{roadmapsList.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
Team does not have any roadmaps yet.{' '}
{canManageTeam && (
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => {
setCreatingRoadmapTeamId(currentTeam?.id);
setIsCreatingRoadmap(true);
}}
>
Create one!
</button>
)}
</p>
)}
{roadmapsList.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{roadmapsList.map((customRoadmap) => {
return (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r/${customRoadmap?.roadmapSlug}`}
allowFavorite={false}
/>
);
})}
{canManageTeam && (
<CreateRoadmapButton
teamId={currentTeam?.id}
text="Create Team Roadmap"
/>
)}
</div>
)}
</div>
);
})}
</div>
);
}

@ -0,0 +1,71 @@
import type { ReactNode } from 'react';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
import { cn } from '../../lib/classname.ts';
type HeroTitleProps = {
icon: any;
isLoading?: boolean;
title: string | ReactNode;
rightContent?: ReactNode;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
isEmpty?: boolean;
emptyTitle?: ReactNode;
};
export function HeroTitle(props: HeroTitleProps) {
const {
isLoading = false,
title,
icon,
rightContent,
isCollapsed = false,
onToggleCollapse,
isEmpty = false,
emptyTitle,
} = props;
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="flex items-center gap-0.5 text-sm text-gray-400">
{!isLoading && icon}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
{!isEmpty ? title : emptyTitle || title}
</p>
</div>
<div className="flex items-center gap-2">
{!isCollapsed && rightContent}
{!isLoading && !isEmpty && (
<button
onClick={onToggleCollapse}
className={cn(
'ml-2 inline-flex items-center gap-1 rounded-md bg-slate-800 py-0.5 pl-1 pr-1.5 text-xs uppercase tracking-wider text-slate-400 hover:bg-slate-700',
{
'bg-slate-800 text-slate-500 hover:bg-slate-800 hover:text-slate-400':
!isCollapsed,
},
)}
>
{isCollapsed && (
<>
<ChevronsUpDown className="h-3.5 w-3.5" /> Expand
</>
)}
{!isCollapsed && (
<>
<ChevronsDownUp className="h-3.5 w-3.5" /> Collapse
</>
)}
</button>
)}
</div>
</div>
);
}

@ -1,8 +1,8 @@
import { httpGet } from '../../lib/http';
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
import { SelectionButton } from './SelectionButton';
import type { UserProgressResponse } from '../Roadmaps/RoadmapsPage';
type RoadmapSelectProps = {
selectedRoadmaps: string[];

@ -10,8 +10,27 @@ import {
} from '../../lib/browser.ts';
import { RoadmapCard } from './RoadmapCard.tsx';
import { httpGet } from '../../lib/http.ts';
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps.tsx';
import { isLoggedIn } from '../../lib/jwt.ts';
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
export type UserProgressResponse = {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
resourceTitle: string;
isFavorite: boolean;
done: number;
learning: number;
skipped: number;
total: number;
updatedAt: Date;
isCustomResource: boolean;
roadmapSlug?: string;
team?: {
name: string;
id: string;
role: AllowedMemberRoles;
};
}[];
const groupNames = [
'Absolute Beginners',

@ -1,16 +1,15 @@
import { useStore } from '@nanostores/react';
import { useEffect, useState } from 'react';
import { useAuth } from '../../hooks/use-auth';
import { useToast } from '../../hooks/use-toast';
import { getUrlParams, setUrlParams } from '../../lib/browser';
import { httpGet } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { MemberProgressItem } from './MemberProgressItem';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { $currentTeam } from '../../stores/team';
import { GroupRoadmapItem } from './GroupRoadmapItem';
import { getUrlParams, setUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import { MemberProgressModal } from './MemberProgressModal';
import { MemberCustomProgressModal } from './MemberCustomProgressModal';
import { canManageCurrentRoadmap } from '../../stores/roadmap.ts';
import { MemberProgressItem } from './MemberProgressItem';
import { MemberProgressModal } from './MemberProgressModal';
export type UserProgress = {
resourceTitle: string;

@ -1,40 +0,0 @@
---
import type { VideoFileType } from '../lib/video';
export interface Props {
video: VideoFileType;
}
const { video } = Astro.props;
const { frontmatter, id } = video;
---
<a
class:list={[
'block no-underline py-2 group text-md items-center text-gray-600 hover:text-blue-600 flex justify-between border-b',
]}
href={`/videos/${id}`}
>
<span class='group-hover:translate-x-2 transition-transform'>
{frontmatter.title}
{
frontmatter.isNew && (
<span class='bg-green-300 text-green-900 text-xs font-medium px-1.5 py-0.5 rounded-sm uppercase ml-1.5'>
New
<span class='hidden sm:inline'>
&middot;
{new Date(frontmatter.date).toLocaleString('default', {
month: 'long',
})}
</span>
</span>
)
}
</span>
<span class='capitalize text-gray-500 text-xs hidden sm:block'>
{frontmatter.duration}
</span>
<span class='text-gray-400 text-xs block sm:hidden'> &raquo;</span>
</a>

@ -3,7 +3,7 @@ import slugify from 'slugify';
import { getAllAuthors, type AuthorFileType } from './author.ts';
import { getAllGuides } from './guide.ts';
interface RawQuestionGroupFrontmatter {
export interface RawQuestionGroupFrontmatter {
order: number;
briefTitle: string;
briefDescription: string;

@ -1,12 +1,12 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AstroIcon from '../../components/AstroIcon.astro';
import { getGuidesByAuthor } from '../../lib/guide';
import { getVideosByAuthor } from '../../lib/video';
import GuideListItem from '../../components/GuideListItem.astro';
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAuthorById, getAuthorIds } from '../../lib/author';
import VideoListItem from '../../components/VideoListItem.astro';
import { getGuidesByAuthor } from '../../lib/guide';
import { getAllQuestionGroups } from '../../lib/question-group';
import { getVideosByAuthor } from '../../lib/video';
interface Params extends Record<string, string | undefined> {}
@ -136,9 +136,12 @@ const videos = await getVideosByAuthor(authorId);
{
[...guides, ...questionGuides]
.sort((a, b) => {
const aDate = a.frontmatter.date || a.frontmatter.publishedAt;
const bDate = b.frontmatter.date || b.frontmatter.publishedAt;
return new Date(bDate) - new Date(aDate);
const aFrontmatter = a.frontmatter as any;
const bFrontmatter = b.frontmatter as any;
const aDate = aFrontmatter.date || aFrontmatter.publishedAt;
const bDate = bFrontmatter.date || bFrontmatter.publishedAt;
return new Date(bDate).getTime() - new Date(aDate).getTime();
})
.map((guide) => <GuideListItem guide={guide} />)
}

@ -2,11 +2,17 @@
import { DashboardPage } from '../components/Dashboard/DashboardPage';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../lib/best-practice';
import { getAllQuestionGroups } from '../lib/question-group';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllGuides } from '../lib/guide';
import { getAllVideos } from '../lib/video';
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
const bestPractices = await getAllBestPractices();
const questionGroups = await getAllQuestionGroups();
const guides = await getAllGuides();
const videos = await getAllVideos();
const enrichedRoleRoadmaps = roleRoadmaps
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
@ -20,6 +26,7 @@ const enrichedRoleRoadmaps = roleRoadmaps
description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
isNew: frontmatter.isNew,
metadata: {
tags: frontmatter.tags,
},
@ -38,6 +45,7 @@ const enrichedSkillRoadmaps = skillRoadmaps
description: frontmatter.briefDescription,
relatedRoadmapIds: frontmatter.relatedRoadmaps,
renderer: frontmatter.renderer,
isNew: frontmatter.isNew,
metadata: {
tags: frontmatter.tags,
},
@ -56,13 +64,16 @@ const enrichedBestPractices = bestPractices.map((bestPractice) => {
});
---
<BaseLayout title='Dashboard' noIndex={true} permalink="/dashboard">
<BaseLayout title='Dashboard' noIndex={true} permalink='/dashboard'>
<DashboardPage
builtInRoleRoadmaps={enrichedRoleRoadmaps}
builtInSkillRoadmaps={enrichedSkillRoadmaps}
builtInBestPractices={enrichedBestPractices}
questionGroups={questionGroups}
guides={guides.slice(0, 7)}
videos={videos.slice(0, 7)}
client:load
/>
<div slot='open-source-banner'></div>
<div slot="changelog-banner" />
<div slot='changelog-banner'></div>
</BaseLayout>

@ -1,5 +1,5 @@
---
import GuideListItem from '../../components/GuideListItem.astro';
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
import SimplePageHeader from '../../components/SimplePageHeader.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllGuides } from '../../lib/guide';
@ -12,8 +12,8 @@ const questionGuides = (await getAllQuestionGroups()).filter(
const allGuides = [...guides, ...questionGuides];
const sortedGuides = allGuides.sort((a, b) => {
const aDate = new Date(a.frontmatter.date);
const bDate = new Date(b.frontmatter.date);
const aDate = new Date(a.frontmatter.date as string);
const bDate = new Date(b.frontmatter.date as string);
return bDate.getTime() - aDate.getTime();
});
@ -36,5 +36,5 @@ const sortedGuides = allGuides.sort((a, b) => {
</div>
</div>
</div>
<div slot="changelog-banner" />
<div slot='changelog-banner'></div>
</BaseLayout>

@ -1,21 +1,22 @@
---
import FeaturedVideos from '../components/FeaturedVideos.astro';
import FeaturedGuides from '../components/FeaturedGuides.astro';
import ChangelogBanner from '../components/ChangelogBanner.astro';
import { FeaturedGuideList } from '../components/FeaturedGuides/FeaturedGuideList';
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
import { FeaturedVideoList } from '../components/FeaturedVideos/FeaturedVideoList';
import HeroSection from '../components/HeroSection/HeroSection.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getAllBestPractices } from '../lib/best-practice';
import { getAllGuides } from '../lib/guide';
import { getAllQuestionGroups } from '../lib/question-group';
import { getRoadmapsByTag } from '../lib/roadmap';
import { getAllVideos } from '../lib/video';
import { getAllQuestionGroups } from '../lib/question-group';
import ChangelogBanner from '../components/ChangelogBanner.astro';
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
const bestPractices = await getAllBestPractices();
const questionGroups = await getAllQuestionGroups();
const projectGroups = [
export const projectGroups = [
{
title: 'Frontend',
id: 'frontend',
@ -104,12 +105,12 @@ const videos = await getAllVideos();
/>
<div class='grid grid-cols-1 gap-7 bg-gray-50 py-7 sm:gap-16 sm:py-16'>
<FeaturedGuides
<FeaturedGuideList
heading='Guides'
guides={guides.slice(0, 7)}
questions={questionGuides.slice(0, 7)}
/>
<FeaturedVideos heading='Videos' videos={videos.slice(0, 7)} />
<FeaturedVideoList heading='Videos' videos={videos.slice(0, 7)} />
</div>
</div>
<ChangelogBanner slot='changelog-banner' />

@ -1,6 +1,6 @@
---
import VideoListItem from '../../components/VideoListItem.astro';
import SimplePageHeader from '../../components/SimplePageHeader.astro';
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getAllVideos } from '../../lib/video';

@ -128,8 +128,24 @@ a > code:before {
animation: barberpole 15s linear infinite;
}
.striped-loader-slate {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
hsla(0, 0%, 0%, 0.1) 5px,
hsla(0, 0%, 0%, 0.1) 10px
);
background-size: 200% 200%;
animation: barberpole 30s linear infinite;
}
.striped-loader-slate-fast {
animation: barberpole 10s linear infinite;
}
@keyframes barberpole {
100% {
background-position: 100% 100%;
}
}
}

Loading…
Cancel
Save