mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-11 19:53:59 +02:00
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
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1738019390029
|
"lastUpdateCheck": 1739229597159
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { httpGet } from '../../lib/http';
|
|
||||||
import { useToast } from '../../hooks/use-toast';
|
|
||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { cn } from '../../../editor/utils/classname';
|
||||||
|
import { useParams } from '../../hooks/use-params';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { httpGet } from '../../lib/http';
|
||||||
|
import { getUser } from '../../lib/jwt';
|
||||||
import { $teamList } from '../../stores/team';
|
import { $teamList } from '../../stores/team';
|
||||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
||||||
import { DashboardTab } from './DashboardTab';
|
import { DashboardTabButton } from './DashboardTabButton';
|
||||||
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
|
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
|
||||||
import { TeamDashboard } from './TeamDashboard';
|
import { TeamDashboard } from './TeamDashboard';
|
||||||
import { getUser } from '../../lib/jwt';
|
import type { QuestionGroupType } from '../../lib/question-group';
|
||||||
import { useParams } from '../../hooks/use-params';
|
import type { GuideFileType } from '../../lib/guide';
|
||||||
|
import type { VideoFileType } from '../../lib/video';
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||||
builtInBestPractices?: BuiltInRoadmap[];
|
builtInBestPractices?: BuiltInRoadmap[];
|
||||||
isTeamPage?: boolean;
|
isTeamPage?: boolean;
|
||||||
|
questionGroups?: QuestionGroupType[];
|
||||||
|
guides?: GuideFileType[];
|
||||||
|
videos?: VideoFileType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DashboardPage(props: DashboardPageProps) {
|
export function DashboardPage(props: DashboardPageProps) {
|
||||||
@@ -23,6 +30,9 @@ export function DashboardPage(props: DashboardPageProps) {
|
|||||||
builtInBestPractices,
|
builtInBestPractices,
|
||||||
builtInSkillRoadmaps,
|
builtInSkillRoadmaps,
|
||||||
isTeamPage = false,
|
isTeamPage = false,
|
||||||
|
questionGroups,
|
||||||
|
guides,
|
||||||
|
videos,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const currentUser = getUser();
|
const currentUser = getUser();
|
||||||
@@ -66,23 +76,21 @@ export function DashboardPage(props: DashboardPageProps) {
|
|||||||
: '/images/default-avatar.png';
|
: '/images/default-avatar.png';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 pb-20 pt-8">
|
<>
|
||||||
<div className="container">
|
<div
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8">
|
className={cn('bg-slate-900', {
|
||||||
<DashboardTab
|
'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"
|
label="Personal"
|
||||||
isActive={!selectedTeamId && !isTeamPage}
|
isActive={!selectedTeamId && !isTeamPage}
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
avatar={userAvatar}
|
avatar={userAvatar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<>
|
|
||||||
<DashboardTabSkeleton />
|
|
||||||
<DashboardTabSkeleton />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<>
|
<>
|
||||||
{teamList.map((team) => {
|
{teamList.map((team) => {
|
||||||
@@ -91,7 +99,7 @@ export function DashboardPage(props: DashboardPageProps) {
|
|||||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||||
: '/images/default-avatar.png';
|
: '/images/default-avatar.png';
|
||||||
return (
|
return (
|
||||||
<DashboardTab
|
<DashboardTabButton
|
||||||
key={team._id}
|
key={team._id}
|
||||||
label={team.name}
|
label={team.name}
|
||||||
isActive={team._id === selectedTeamId}
|
isActive={team._id === selectedTeamId}
|
||||||
@@ -106,38 +114,42 @@ export function DashboardPage(props: DashboardPageProps) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<DashboardTab
|
<DashboardTabButton
|
||||||
label="+ Create Team"
|
label="+ Create Team"
|
||||||
isActive={false}
|
isActive={false}
|
||||||
href="/team/new"
|
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"
|
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>
|
||||||
|
|
||||||
|
<div className="">
|
||||||
{!selectedTeamId && !isTeamPage && (
|
{!selectedTeamId && !isTeamPage && (
|
||||||
|
<div className="bg-slate-900">
|
||||||
<PersonalDashboard
|
<PersonalDashboard
|
||||||
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
||||||
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
||||||
builtInBestPractices={builtInBestPractices}
|
builtInBestPractices={builtInBestPractices}
|
||||||
|
questionGroups={questionGroups}
|
||||||
|
guides={guides}
|
||||||
|
videos={videos}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(selectedTeamId || isTeamPage) && (
|
{(selectedTeamId || isTeamPage) && (
|
||||||
|
<div className="container">
|
||||||
<TeamDashboard
|
<TeamDashboard
|
||||||
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
||||||
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
||||||
teamId={selectedTeamId!}
|
teamId={selectedTeamId!}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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;
|
icon?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DashboardTab(props: DashboardTabProps) {
|
export function DashboardTabButton(props: DashboardTabProps) {
|
||||||
const { isActive, onClick, label, className, href, avatar, icon } = props;
|
const { isActive, onClick, label, className, href, avatar, icon } = props;
|
||||||
|
|
||||||
const Slot = href ? 'a' : 'button';
|
const Slot = href ? 'a' : 'button';
|
||||||
@@ -20,8 +20,10 @@ export function DashboardTab(props: DashboardTabProps) {
|
|||||||
<Slot
|
<Slot
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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',
|
'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-gray-500 bg-gray-200 text-gray-900' : '',
|
isActive
|
||||||
|
? 'border-slate-200 bg-slate-200 text-gray-900 hover:bg-slate-200'
|
||||||
|
: '',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...(href ? { href } : {})}
|
{...(href ? { href } : {})}
|
||||||
@@ -30,7 +32,7 @@ export function DashboardTab(props: DashboardTabProps) {
|
|||||||
<img
|
<img
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt="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}
|
{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 { useStore } from '@nanostores/react';
|
||||||
import { $accountStreak, type StreakResponse } from '../../stores/streak';
|
import {
|
||||||
import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx';
|
ChartColumn,
|
||||||
import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx';
|
CheckCircle,
|
||||||
import { BookEmoji } from '../ReactIcons/BookEmoji.tsx';
|
CheckSquare,
|
||||||
import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx';
|
FolderGit2,
|
||||||
|
Pencil,
|
||||||
|
SquarePen,
|
||||||
|
Zap,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import type { AllowedProfileVisibility } from '../../api/user.ts';
|
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 { cn } from '../../lib/classname.ts';
|
||||||
|
import { httpGet } from '../../lib/http';
|
||||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
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 = {
|
type UserDashboardResponse = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,11 +40,7 @@ type UserDashboardResponse = {
|
|||||||
profileVisibility: AllowedProfileVisibility;
|
profileVisibility: AllowedProfileVisibility;
|
||||||
progresses: UserProgress[];
|
progresses: UserProgress[];
|
||||||
projects: ProjectStatusDocument[];
|
projects: ProjectStatusDocument[];
|
||||||
aiRoadmaps: {
|
aiRoadmaps: AIRoadmapType[];
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
}[];
|
|
||||||
topicDoneToday: number;
|
topicDoneToday: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +50,7 @@ export type BuiltInRoadmap = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
|
isNew?: boolean;
|
||||||
relatedRoadmapIds?: string[];
|
relatedRoadmapIds?: string[];
|
||||||
renderer?: AllowedRoadmapRenderer;
|
renderer?: AllowedRoadmapRenderer;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
@@ -51,16 +60,162 @@ type PersonalDashboardProps = {
|
|||||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||||
builtInBestPractices?: 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) {
|
export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||||
const {
|
const {
|
||||||
builtInRoleRoadmaps = [],
|
builtInRoleRoadmaps = [],
|
||||||
builtInBestPractices = [],
|
builtInBestPractices = [],
|
||||||
builtInSkillRoadmaps = [],
|
builtInSkillRoadmaps = [],
|
||||||
|
questionGroups = [],
|
||||||
|
guides = [],
|
||||||
|
videos = [],
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [personalDashboardDetails, setPersonalDashboardDetails] =
|
const [personalDashboardDetails, setPersonalDashboardDetails] =
|
||||||
useState<UserDashboardResponse>();
|
useState<UserDashboardResponse>();
|
||||||
@@ -138,7 +293,9 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
|||||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const learningRoadmapsToShow = (personalDashboardDetails?.progresses || [])
|
const learningRoadmapsToShow: UserProgress[] = (
|
||||||
|
personalDashboardDetails?.progresses || []
|
||||||
|
)
|
||||||
.filter((progress) => !progress.isCustomResource)
|
.filter((progress) => !progress.isCustomResource)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const updatedAtA = new Date(a.updatedAt);
|
const updatedAtA = new Date(a.updatedAt);
|
||||||
@@ -156,7 +313,10 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
|
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
|
||||||
const customRoadmaps = (personalDashboardDetails?.progresses || [])
|
|
||||||
|
const customRoadmaps: UserProgress[] = (
|
||||||
|
personalDashboardDetails?.progresses || []
|
||||||
|
)
|
||||||
.filter((progress) => progress.isCustomResource)
|
.filter((progress) => progress.isCustomResource)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const updatedAtA = new Date(a.updatedAt);
|
const updatedAtA = new Date(a.updatedAt);
|
||||||
@@ -169,43 +329,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
|||||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||||
: '/images/default-avatar.png';
|
: '/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
|
const enrichedProjects = personalDashboardDetails?.projects
|
||||||
.map((project) => {
|
.map((project) => {
|
||||||
const projectDetail = projectDetails.find(
|
const projectDetail = projectDetails.find(
|
||||||
@@ -232,165 +355,200 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
|||||||
const { username } = personalDashboardDetails || {};
|
const { username } = personalDashboardDetails || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div>
|
||||||
{isLoading ? (
|
<DashboardStats
|
||||||
<div className="h-7 w-1/4 animate-pulse rounded-lg bg-gray-200"></div>
|
profile={{
|
||||||
) : (
|
name,
|
||||||
<div className="flex flex-col items-start justify-between gap-1 sm:flex-row sm:items-center">
|
username,
|
||||||
<h2 className="text-lg font-medium">
|
avatar: avatarLink,
|
||||||
Hi {name}, good {getCurrentPeriod()}!
|
isLoading,
|
||||||
</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 || []}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
accountStreak={accountStreak}
|
accountStreak={accountStreak}
|
||||||
topicDoneToday={personalDashboardDetails?.topicDoneToday || 0}
|
topicsDoneToday={personalDashboardDetails?.topicDoneToday}
|
||||||
|
finishedProjectsCount={
|
||||||
|
enrichedProjects?.filter((p) => p.submittedAt && p.repositoryUrl)
|
||||||
|
.length
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListDashboardCustomProgress
|
<FavoriteRoadmaps
|
||||||
progresses={customRoadmaps}
|
progress={learningRoadmapsToShow}
|
||||||
|
customRoadmaps={customRoadmaps}
|
||||||
|
aiRoadmaps={aiGeneratedRoadmaps}
|
||||||
|
projects={enrichedProjects || []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardAiRoadmaps
|
<div className="bg-gradient-to-b from-slate-900 to-black pb-12">
|
||||||
roadmaps={aiGeneratedRoadmaps}
|
<div className="relative mt-6 border-t border-t-[#1e293c] pt-12">
|
||||||
isLoading={isLoading}
|
<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>
|
||||||
|
|
||||||
<RecommendedRoadmaps
|
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||||
roadmaps={recommendedRoadmaps}
|
{builtInRoleRoadmaps.map((roadmap) => {
|
||||||
isLoading={isLoading}
|
const roadmapProgress = learningRoadmapsToShow.find(
|
||||||
/>
|
(lr) => lr.resourceId === roadmap.id,
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardCardProps = {
|
const percentageDone =
|
||||||
icon?: JSXElementConstructor<any>;
|
(((roadmapProgress?.skipped || 0) +
|
||||||
imgUrl?: string;
|
(roadmapProgress?.done || 0)) /
|
||||||
title: string;
|
(roadmapProgress?.total || 1)) *
|
||||||
description: string;
|
100;
|
||||||
href: string;
|
|
||||||
externalLinkIcon?: LucideIcon;
|
|
||||||
externalLinkText?: string;
|
|
||||||
externalLinkHref?: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function DashboardCard(props: DashboardCardProps) {
|
|
||||||
const {
|
|
||||||
icon: Icon,
|
|
||||||
imgUrl,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
href,
|
|
||||||
externalLinkHref,
|
|
||||||
externalLinkIcon: ExternalLinkIcon,
|
|
||||||
externalLinkText,
|
|
||||||
className,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative overflow-hidden', className)}>
|
<HeroRoadmap
|
||||||
<a
|
key={roadmap.id}
|
||||||
href={href}
|
resourceId={roadmap.id}
|
||||||
className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50"
|
resourceType="roadmap"
|
||||||
>
|
resourceTitle={roadmap.title}
|
||||||
{Icon && (
|
isFavorite={roadmap.isFavorite}
|
||||||
<div className="px-4 pb-3 pt-4">
|
percentageDone={percentageDone}
|
||||||
<Icon className="size-6" />
|
isNew={roadmap.isNew}
|
||||||
</div>
|
url={`/${roadmap.id}`}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{imgUrl && (
|
|
||||||
<div className="px-4 pb-1.5 pt-3.5">
|
|
||||||
<img src={imgUrl} alt={title} className="size-8 rounded-full" />
|
|
||||||
</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>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
|
||||||
function DashboardCardSkeleton() {
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[128px] animate-pulse rounded-lg border border-gray-300 bg-white"></div>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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 →
|
|
||||||
</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 →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
51
src/components/FeaturedGuides/FeaturedGuideList.tsx
Normal file
51
src/components/FeaturedGuides/FeaturedGuideList.tsx
Normal file
@@ -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 →
|
||||||
|
</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 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
57
src/components/FeaturedGuides/GuideListItem.tsx
Normal file
57
src/components/FeaturedGuides/GuideListItem.tsx
Normal file
@@ -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">
|
||||||
|
·
|
||||||
|
{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"> »</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 →
|
|
||||||
</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 →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
39
src/components/FeaturedVideos/FeaturedVideoList.tsx
Normal file
39
src/components/FeaturedVideos/FeaturedVideoList.tsx
Normal file
@@ -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 →
|
||||||
|
</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 →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
38
src/components/FeaturedVideos/VideoListItem.tsx
Normal file
38
src/components/FeaturedVideos/VideoListItem.tsx
Normal file
@@ -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">
|
||||||
|
·
|
||||||
|
{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"> »</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'>
|
|
||||||
·
|
|
||||||
{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'> »</span>
|
|
||||||
</a>
|
|
@@ -1,164 +1,229 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import {
|
||||||
import { EmptyProgress } from './EmptyProgress';
|
FolderKanban,
|
||||||
import { httpGet } from '../../lib/http';
|
MapIcon,
|
||||||
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
|
Plus,
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
Sparkle,
|
||||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
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 UserProgressResponse = {
|
export type AIRoadmapType = {
|
||||||
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;
|
id: string;
|
||||||
role: AllowedMemberRoles;
|
title: string;
|
||||||
};
|
slug: string;
|
||||||
}[];
|
};
|
||||||
|
|
||||||
function renderProgress(progressList: UserProgressResponse) {
|
type FavoriteRoadmapsProps = {
|
||||||
progressList.forEach((progress) => {
|
progress: UserProgress[];
|
||||||
const href =
|
projects: (ProjectStatusDocument & {
|
||||||
progress.resourceType === 'best-practice'
|
title: string;
|
||||||
? `/best-practices/${progress.resourceId}`
|
})[];
|
||||||
: `/${progress.resourceId}`;
|
customRoadmaps: UserProgress[];
|
||||||
const element = document.querySelector(`a[href="${href}"]`);
|
aiRoadmaps: AIRoadmapType[];
|
||||||
if (!element) {
|
isLoading: boolean;
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
|
||||||
new CustomEvent('mark-favorite', {
|
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props;
|
||||||
detail: {
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
resourceId: progress.resourceId,
|
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false);
|
||||||
resourceType: progress.resourceType,
|
|
||||||
isFavorite: progress.isFavorite,
|
const completedProjects = projects.filter(
|
||||||
},
|
(project) => project.submittedAt && project.repositoryUrl,
|
||||||
}),
|
);
|
||||||
|
const inProgressProjects = projects.filter(
|
||||||
|
(project) => !project.submittedAt || !project.repositoryUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalDone = progress.done + progress.skipped;
|
const projectsToShow = [
|
||||||
const percentageDone = (totalDone / progress.total) * 100;
|
...inProgressProjects,
|
||||||
|
...(showCompleted ? completedProjects : []),
|
||||||
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,
|
|
||||||
);
|
|
||||||
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;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col">
|
||||||
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
|
{isCreatingCustomRoadmap && (
|
||||||
>
|
<CreateRoadmapModal
|
||||||
<div
|
onClose={() => {
|
||||||
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
|
setIsCreatingCustomRoadmap(false);
|
||||||
hasProgress && `border-t border-t-[#1e293c]`
|
}}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="container min-h-full">
|
|
||||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
|
||||||
{hasProgress && (
|
|
||||||
<HeroRoadmaps
|
|
||||||
teamRoadmaps={teamRoadmaps}
|
|
||||||
customRoadmaps={customRoadmaps}
|
|
||||||
progress={defaultRoadmaps}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
<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"
|
||||||
|
>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
78
src/components/HeroSection/HeroItemsGroup.tsx
Normal file
78
src/components/HeroSection/HeroItemsGroup.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
52
src/components/HeroSection/HeroProject.tsx
Normal file
52
src/components/HeroSection/HeroProject.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
74
src/components/HeroSection/HeroRoadmap.tsx
Normal file
74
src/components/HeroSection/HeroRoadmap.tsx
Normal file
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
71
src/components/HeroSection/HeroTitle.tsx
Normal file
71
src/components/HeroSection/HeroTitle.tsx
Normal file
@@ -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 { httpGet } from '../../lib/http';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
|
|
||||||
import { SelectionButton } from './SelectionButton';
|
import { SelectionButton } from './SelectionButton';
|
||||||
|
import type { UserProgressResponse } from '../Roadmaps/RoadmapsPage';
|
||||||
|
|
||||||
type RoadmapSelectProps = {
|
type RoadmapSelectProps = {
|
||||||
selectedRoadmaps: string[];
|
selectedRoadmaps: string[];
|
||||||
|
@@ -10,8 +10,27 @@ import {
|
|||||||
} from '../../lib/browser.ts';
|
} from '../../lib/browser.ts';
|
||||||
import { RoadmapCard } from './RoadmapCard.tsx';
|
import { RoadmapCard } from './RoadmapCard.tsx';
|
||||||
import { httpGet } from '../../lib/http.ts';
|
import { httpGet } from '../../lib/http.ts';
|
||||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps.tsx';
|
|
||||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
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 = [
|
const groupNames = [
|
||||||
'Absolute Beginners',
|
'Absolute Beginners',
|
||||||
|
@@ -1,16 +1,15 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
import { useEffect, useState } from '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 { httpGet } from '../../lib/http';
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
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 { $currentTeam } from '../../stores/team';
|
||||||
import { GroupRoadmapItem } from './GroupRoadmapItem';
|
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 { MemberCustomProgressModal } from './MemberCustomProgressModal';
|
||||||
import { canManageCurrentRoadmap } from '../../stores/roadmap.ts';
|
import { MemberProgressItem } from './MemberProgressItem';
|
||||||
|
import { MemberProgressModal } from './MemberProgressModal';
|
||||||
|
|
||||||
export type UserProgress = {
|
export type UserProgress = {
|
||||||
resourceTitle: string;
|
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'>
|
|
||||||
·
|
|
||||||
{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'> »</span>
|
|
||||||
</a>
|
|
@@ -3,7 +3,7 @@ import slugify from 'slugify';
|
|||||||
import { getAllAuthors, type AuthorFileType } from './author.ts';
|
import { getAllAuthors, type AuthorFileType } from './author.ts';
|
||||||
import { getAllGuides } from './guide.ts';
|
import { getAllGuides } from './guide.ts';
|
||||||
|
|
||||||
interface RawQuestionGroupFrontmatter {
|
export interface RawQuestionGroupFrontmatter {
|
||||||
order: number;
|
order: number;
|
||||||
briefTitle: string;
|
briefTitle: string;
|
||||||
briefDescription: string;
|
briefDescription: string;
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
|
||||||
import AstroIcon from '../../components/AstroIcon.astro';
|
import AstroIcon from '../../components/AstroIcon.astro';
|
||||||
import { getGuidesByAuthor } from '../../lib/guide';
|
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
|
||||||
import { getVideosByAuthor } from '../../lib/video';
|
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
|
||||||
import GuideListItem from '../../components/GuideListItem.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
||||||
import VideoListItem from '../../components/VideoListItem.astro';
|
import { getGuidesByAuthor } from '../../lib/guide';
|
||||||
import { getAllQuestionGroups } from '../../lib/question-group';
|
import { getAllQuestionGroups } from '../../lib/question-group';
|
||||||
|
import { getVideosByAuthor } from '../../lib/video';
|
||||||
|
|
||||||
interface Params extends Record<string, string | undefined> {}
|
interface Params extends Record<string, string | undefined> {}
|
||||||
|
|
||||||
@@ -136,9 +136,12 @@ const videos = await getVideosByAuthor(authorId);
|
|||||||
{
|
{
|
||||||
[...guides, ...questionGuides]
|
[...guides, ...questionGuides]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aDate = a.frontmatter.date || a.frontmatter.publishedAt;
|
const aFrontmatter = a.frontmatter as any;
|
||||||
const bDate = b.frontmatter.date || b.frontmatter.publishedAt;
|
const bFrontmatter = b.frontmatter as any;
|
||||||
return new Date(bDate) - new Date(aDate);
|
|
||||||
|
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} />)
|
.map((guide) => <GuideListItem guide={guide} />)
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,17 @@
|
|||||||
import { DashboardPage } from '../components/Dashboard/DashboardPage';
|
import { DashboardPage } from '../components/Dashboard/DashboardPage';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getAllBestPractices } from '../lib/best-practice';
|
import { getAllBestPractices } from '../lib/best-practice';
|
||||||
|
import { getAllQuestionGroups } from '../lib/question-group';
|
||||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||||
|
import { getAllGuides } from '../lib/guide';
|
||||||
|
import { getAllVideos } from '../lib/video';
|
||||||
|
|
||||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
||||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
||||||
const bestPractices = await getAllBestPractices();
|
const bestPractices = await getAllBestPractices();
|
||||||
|
const questionGroups = await getAllQuestionGroups();
|
||||||
|
const guides = await getAllGuides();
|
||||||
|
const videos = await getAllVideos();
|
||||||
|
|
||||||
const enrichedRoleRoadmaps = roleRoadmaps
|
const enrichedRoleRoadmaps = roleRoadmaps
|
||||||
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
|
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
|
||||||
@@ -20,6 +26,7 @@ const enrichedRoleRoadmaps = roleRoadmaps
|
|||||||
description: frontmatter.briefDescription,
|
description: frontmatter.briefDescription,
|
||||||
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
||||||
renderer: frontmatter.renderer,
|
renderer: frontmatter.renderer,
|
||||||
|
isNew: frontmatter.isNew,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: frontmatter.tags,
|
tags: frontmatter.tags,
|
||||||
},
|
},
|
||||||
@@ -38,6 +45,7 @@ const enrichedSkillRoadmaps = skillRoadmaps
|
|||||||
description: frontmatter.briefDescription,
|
description: frontmatter.briefDescription,
|
||||||
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
||||||
renderer: frontmatter.renderer,
|
renderer: frontmatter.renderer,
|
||||||
|
isNew: frontmatter.isNew,
|
||||||
metadata: {
|
metadata: {
|
||||||
tags: frontmatter.tags,
|
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
|
<DashboardPage
|
||||||
builtInRoleRoadmaps={enrichedRoleRoadmaps}
|
builtInRoleRoadmaps={enrichedRoleRoadmaps}
|
||||||
builtInSkillRoadmaps={enrichedSkillRoadmaps}
|
builtInSkillRoadmaps={enrichedSkillRoadmaps}
|
||||||
builtInBestPractices={enrichedBestPractices}
|
builtInBestPractices={enrichedBestPractices}
|
||||||
|
questionGroups={questionGroups}
|
||||||
|
guides={guides.slice(0, 7)}
|
||||||
|
videos={videos.slice(0, 7)}
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
<div slot='open-source-banner'></div>
|
<div slot='open-source-banner'></div>
|
||||||
<div slot="changelog-banner" />
|
<div slot='changelog-banner'></div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import GuideListItem from '../../components/GuideListItem.astro';
|
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
|
||||||
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getAllGuides } from '../../lib/guide';
|
import { getAllGuides } from '../../lib/guide';
|
||||||
@@ -12,8 +12,8 @@ const questionGuides = (await getAllQuestionGroups()).filter(
|
|||||||
|
|
||||||
const allGuides = [...guides, ...questionGuides];
|
const allGuides = [...guides, ...questionGuides];
|
||||||
const sortedGuides = allGuides.sort((a, b) => {
|
const sortedGuides = allGuides.sort((a, b) => {
|
||||||
const aDate = new Date(a.frontmatter.date);
|
const aDate = new Date(a.frontmatter.date as string);
|
||||||
const bDate = new Date(b.frontmatter.date);
|
const bDate = new Date(b.frontmatter.date as string);
|
||||||
|
|
||||||
return bDate.getTime() - aDate.getTime();
|
return bDate.getTime() - aDate.getTime();
|
||||||
});
|
});
|
||||||
@@ -36,5 +36,5 @@ const sortedGuides = allGuides.sort((a, b) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div slot="changelog-banner" />
|
<div slot='changelog-banner'></div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
@@ -1,21 +1,22 @@
|
|||||||
---
|
---
|
||||||
import FeaturedVideos from '../components/FeaturedVideos.astro';
|
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||||
import FeaturedGuides from '../components/FeaturedGuides.astro';
|
import { FeaturedGuideList } from '../components/FeaturedGuides/FeaturedGuideList';
|
||||||
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
|
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
|
||||||
|
import { FeaturedVideoList } from '../components/FeaturedVideos/FeaturedVideoList';
|
||||||
import HeroSection from '../components/HeroSection/HeroSection.astro';
|
import HeroSection from '../components/HeroSection/HeroSection.astro';
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import { getAllBestPractices } from '../lib/best-practice';
|
import { getAllBestPractices } from '../lib/best-practice';
|
||||||
import { getAllGuides } from '../lib/guide';
|
import { getAllGuides } from '../lib/guide';
|
||||||
|
import { getAllQuestionGroups } from '../lib/question-group';
|
||||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||||
import { getAllVideos } from '../lib/video';
|
import { getAllVideos } from '../lib/video';
|
||||||
import { getAllQuestionGroups } from '../lib/question-group';
|
|
||||||
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
|
||||||
|
|
||||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
||||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
||||||
const bestPractices = await getAllBestPractices();
|
const bestPractices = await getAllBestPractices();
|
||||||
const questionGroups = await getAllQuestionGroups();
|
const questionGroups = await getAllQuestionGroups();
|
||||||
const projectGroups = [
|
|
||||||
|
export const projectGroups = [
|
||||||
{
|
{
|
||||||
title: 'Frontend',
|
title: 'Frontend',
|
||||||
id: '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'>
|
<div class='grid grid-cols-1 gap-7 bg-gray-50 py-7 sm:gap-16 sm:py-16'>
|
||||||
<FeaturedGuides
|
<FeaturedGuideList
|
||||||
heading='Guides'
|
heading='Guides'
|
||||||
guides={guides.slice(0, 7)}
|
guides={guides.slice(0, 7)}
|
||||||
questions={questionGuides.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>
|
||||||
</div>
|
</div>
|
||||||
<ChangelogBanner slot='changelog-banner' />
|
<ChangelogBanner slot='changelog-banner' />
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import VideoListItem from '../../components/VideoListItem.astro';
|
|
||||||
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
||||||
|
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import { getAllVideos } from '../../lib/video';
|
import { getAllVideos } from '../../lib/video';
|
||||||
|
|
||||||
|
@@ -128,6 +128,22 @@ a > code:before {
|
|||||||
animation: barberpole 15s linear infinite;
|
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 {
|
@keyframes barberpole {
|
||||||
100% {
|
100% {
|
||||||
background-position: 100% 100%;
|
background-position: 100% 100%;
|
||||||
|
Reference in New Issue
Block a user