mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-14 18:51:53 +08:00
Compare commits
6 Commits
dashboard
...
fix/member
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
749648e94b | ||
|
|
8d3a3aba35 | ||
|
|
fbf3e6577a | ||
|
|
66119e935b | ||
|
|
3374fafe5b | ||
|
|
8ed47a2e71 |
@@ -3,6 +3,6 @@
|
||||
"enabled": false
|
||||
},
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1739229597159
|
||||
"lastUpdateCheck": 1738019390029
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1326,16 +1326,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"LncTxPg-wx8loy55r5NmV": {
|
||||
"queu-based-load-leveling@LncTxPg-wx8loy55r5NmV.md": {
|
||||
"title": "Queu-based Load Leveling",
|
||||
"description": "Use a queue that acts as a buffer between a task and a service it invokes in order to smooth intermittent heavy loads that can cause the service to fail or the task to time out. This can help to minimize the impact of peaks in demand on availability and responsiveness for both the task and the service.\n\nLearn more from the following links:",
|
||||
"links": [
|
||||
{
|
||||
"title": "Queue-Based Load Leveling pattern",
|
||||
"url": "https://learn.microsoft.com/en-us/azure/architecture/patterns/queue-based-load-leveling",
|
||||
"type": "article"
|
||||
}
|
||||
]
|
||||
"description": "",
|
||||
"links": []
|
||||
},
|
||||
"2ryzJhRDTo98gGgn9mAxR": {
|
||||
"title": "Publisher/Subscriber",
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
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 { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $teamList } from '../../stores/team';
|
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
||||
import { DashboardTabButton } from './DashboardTabButton';
|
||||
import { DashboardTab } from './DashboardTab';
|
||||
import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard';
|
||||
import { TeamDashboard } from './TeamDashboard';
|
||||
import type { QuestionGroupType } from '../../lib/question-group';
|
||||
import type { GuideFileType } from '../../lib/guide';
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
import { useParams } from '../../hooks/use-params';
|
||||
|
||||
type DashboardPageProps = {
|
||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||
builtInBestPractices?: BuiltInRoadmap[];
|
||||
isTeamPage?: boolean;
|
||||
questionGroups?: QuestionGroupType[];
|
||||
guides?: GuideFileType[];
|
||||
videos?: VideoFileType[];
|
||||
};
|
||||
|
||||
export function DashboardPage(props: DashboardPageProps) {
|
||||
@@ -30,9 +23,6 @@ export function DashboardPage(props: DashboardPageProps) {
|
||||
builtInBestPractices,
|
||||
builtInSkillRoadmaps,
|
||||
isTeamPage = false,
|
||||
questionGroups,
|
||||
guides,
|
||||
videos,
|
||||
} = props;
|
||||
|
||||
const currentUser = getUser();
|
||||
@@ -76,80 +66,78 @@ export function DashboardPage(props: DashboardPageProps) {
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('bg-slate-900', {
|
||||
'striped-loader-slate': isLoading,
|
||||
})}
|
||||
>
|
||||
<div className="bg-slate-800/30 py-5">
|
||||
<div className="container flex flex-wrap items-center gap-1.5">
|
||||
<DashboardTabButton
|
||||
label="Personal"
|
||||
isActive={!selectedTeamId && !isTeamPage}
|
||||
href="/dashboard"
|
||||
avatar={userAvatar}
|
||||
/>
|
||||
<div className="min-h-screen bg-gray-50 pb-20 pt-8">
|
||||
<div className="container">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-1.5 sm:mb-8">
|
||||
<DashboardTab
|
||||
label="Personal"
|
||||
isActive={!selectedTeamId && !isTeamPage}
|
||||
href="/dashboard"
|
||||
avatar={userAvatar}
|
||||
/>
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{teamList.map((team) => {
|
||||
const { avatar } = team;
|
||||
const avatarUrl = avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
return (
|
||||
<DashboardTabButton
|
||||
key={team._id}
|
||||
label={team.name}
|
||||
isActive={team._id === selectedTeamId}
|
||||
{...(team.status === 'invited'
|
||||
? {
|
||||
href: `/respond-invite?i=${team.memberId}`,
|
||||
}
|
||||
: {
|
||||
href: `/team?t=${team._id}`,
|
||||
})}
|
||||
avatar={avatarUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DashboardTabButton
|
||||
label="+ Create Team"
|
||||
isActive={false}
|
||||
href="/team/new"
|
||||
className="border border-dashed border-slate-700 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-solid hover:border-slate-700 hover:text-gray-400"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<>
|
||||
<DashboardTabSkeleton />
|
||||
<DashboardTabSkeleton />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{teamList.map((team) => {
|
||||
const { avatar } = team;
|
||||
const avatarUrl = avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
return (
|
||||
<DashboardTab
|
||||
key={team._id}
|
||||
label={team.name}
|
||||
isActive={team._id === selectedTeamId}
|
||||
{...(team.status === 'invited'
|
||||
? {
|
||||
href: `/respond-invite?i=${team.memberId}`,
|
||||
}
|
||||
: {
|
||||
href: `/team?t=${team._id}`,
|
||||
})}
|
||||
avatar={avatarUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DashboardTab
|
||||
label="+ Create Team"
|
||||
isActive={false}
|
||||
href="/team/new"
|
||||
className="border border-dashed border-gray-300 bg-transparent px-3 text-[13px] text-sm text-gray-500 hover:border-gray-600 hover:text-black"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{!selectedTeamId && !isTeamPage && (
|
||||
<div className="bg-slate-900">
|
||||
<PersonalDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
||||
builtInBestPractices={builtInBestPractices}
|
||||
questionGroups={questionGroups}
|
||||
guides={guides}
|
||||
videos={videos}
|
||||
/>
|
||||
</div>
|
||||
<PersonalDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps}
|
||||
builtInBestPractices={builtInBestPractices}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(selectedTeamId || isTeamPage) && (
|
||||
<div className="container">
|
||||
<TeamDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
||||
teamId={selectedTeamId!}
|
||||
/>
|
||||
</div>
|
||||
<TeamDashboard
|
||||
builtInRoleRoadmaps={builtInRoleRoadmaps!}
|
||||
builtInSkillRoadmaps={builtInSkillRoadmaps!}
|
||||
teamId={selectedTeamId!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardTabSkeleton() {
|
||||
return (
|
||||
<div className="h-[30px] w-[114px] animate-pulse rounded-md border bg-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ type DashboardTabProps = {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function DashboardTabButton(props: DashboardTabProps) {
|
||||
export function DashboardTab(props: DashboardTabProps) {
|
||||
const { isActive, onClick, label, className, href, avatar, icon } = props;
|
||||
|
||||
const Slot = href ? 'a' : 'button';
|
||||
@@ -20,10 +20,8 @@ export function DashboardTabButton(props: DashboardTabProps) {
|
||||
<Slot
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border border-slate-700 bg-slate-800 p-1.5 pl-2 pr-3 text-sm leading-none text-gray-400 transition-colors hover:bg-slate-700',
|
||||
isActive
|
||||
? 'border-slate-200 bg-slate-200 text-gray-900 hover:bg-slate-200'
|
||||
: '',
|
||||
'flex h-[30px] shrink-0 items-center gap-1 rounded-md border bg-white p-1.5 px-2 text-sm leading-none text-gray-600',
|
||||
isActive ? 'border-gray-500 bg-gray-200 text-gray-900' : '',
|
||||
className,
|
||||
)}
|
||||
{...(href ? { href } : {})}
|
||||
@@ -32,7 +30,7 @@ export function DashboardTabButton(props: DashboardTabProps) {
|
||||
<img
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
className="mr-0.5 h-4 w-4 rounded-full object-cover"
|
||||
className="h-4 w-4 mr-0.5 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
{icon}
|
||||
@@ -1,35 +1,23 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import {
|
||||
ChartColumn,
|
||||
CheckCircle,
|
||||
CheckSquare,
|
||||
FolderGit2,
|
||||
Pencil,
|
||||
SquarePen,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AllowedProfileVisibility } from '../../api/user.ts';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { type JSXElementConstructor, useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import {
|
||||
FavoriteRoadmaps,
|
||||
type AIRoadmapType,
|
||||
} from '../HeroSection/FavoriteRoadmaps.tsx';
|
||||
import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
|
||||
import { projectGroups } from '../../pages/index.astro';
|
||||
import type { QuestionGroupType } from '../../lib/question-group';
|
||||
import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList';
|
||||
import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList';
|
||||
import type { GuideFileType } from '../../lib/guide';
|
||||
import type { VideoFileType } from '../../lib/video';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { getCurrentPeriod } from '../../lib/date';
|
||||
import { ListDashboardCustomProgress } from './ListDashboardCustomProgress';
|
||||
import { RecommendedRoadmaps } from './RecommendedRoadmaps';
|
||||
import { ProgressStack } from './ProgressStack';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $accountStreak, type StreakResponse } from '../../stores/streak';
|
||||
import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx';
|
||||
import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx';
|
||||
import { BookEmoji } from '../ReactIcons/BookEmoji.tsx';
|
||||
import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx';
|
||||
import type { AllowedProfileVisibility } from '../../api/user.ts';
|
||||
import { PencilIcon, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
|
||||
type UserDashboardResponse = {
|
||||
name: string;
|
||||
@@ -40,7 +28,11 @@ type UserDashboardResponse = {
|
||||
profileVisibility: AllowedProfileVisibility;
|
||||
progresses: UserProgress[];
|
||||
projects: ProjectStatusDocument[];
|
||||
aiRoadmaps: AIRoadmapType[];
|
||||
aiRoadmaps: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
}[];
|
||||
topicDoneToday: number;
|
||||
};
|
||||
|
||||
@@ -50,7 +42,6 @@ export type BuiltInRoadmap = {
|
||||
title: string;
|
||||
description: string;
|
||||
isFavorite?: boolean;
|
||||
isNew?: boolean;
|
||||
relatedRoadmapIds?: string[];
|
||||
renderer?: AllowedRoadmapRenderer;
|
||||
metadata?: Record<string, any>;
|
||||
@@ -60,162 +51,16 @@ type PersonalDashboardProps = {
|
||||
builtInRoleRoadmaps?: BuiltInRoadmap[];
|
||||
builtInSkillRoadmaps?: BuiltInRoadmap[];
|
||||
builtInBestPractices?: BuiltInRoadmap[];
|
||||
questionGroups?: QuestionGroupType[];
|
||||
guides?: GuideFileType[];
|
||||
videos?: VideoFileType[];
|
||||
};
|
||||
|
||||
type DashboardStatItemProps = {
|
||||
icon: LucideIcon;
|
||||
iconClassName: string;
|
||||
value: number;
|
||||
label: string;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function DashboardStatItem(props: DashboardStatItemProps) {
|
||||
const { icon: Icon, iconClassName, value, label, isLoading } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3',
|
||||
{
|
||||
'striped-loader-slate striped-loader-slate-fast text-transparent':
|
||||
isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
className={cn(iconClassName, { 'text-transparent': isLoading })}
|
||||
/>
|
||||
<span>
|
||||
<span className="tabular-nums">{value}</span> {label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileButtonProps = {
|
||||
isLoading: boolean;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
function PersonalProfileButton(props: ProfileButtonProps) {
|
||||
const { isLoading, name, username, avatar } = props;
|
||||
|
||||
if (isLoading || !username) {
|
||||
return (
|
||||
<a
|
||||
href="/account/update-profile"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 font-medium outline-slate-700 hover:bg-slate-800 hover:outline-slate-400',
|
||||
{
|
||||
'striped-loader-slate striped-loader-slate-fast text-transparent':
|
||||
isLoading,
|
||||
'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20': !isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<CheckSquare className="h-4 w-4" strokeWidth={2.5} />
|
||||
Set up your profile
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
<a
|
||||
href={`/u/${username}`}
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-300 transition-colors hover:bg-slate-800/70"
|
||||
>
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name || 'Profile'}
|
||||
className="h-5 w-5 rounded-full ring-1 ring-slate-700"
|
||||
/>
|
||||
<span className="font-medium">Visit Profile</span>
|
||||
</a>
|
||||
<a
|
||||
href="/account/update-profile"
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-800/50 py-2 pl-3 pr-3 text-slate-400 transition-colors hover:bg-slate-800/70 hover:text-slate-300"
|
||||
title="Edit Profile"
|
||||
>
|
||||
<SquarePen className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DashboardStatsProps = {
|
||||
profile: ProfileButtonProps;
|
||||
accountStreak?: StreakResponse;
|
||||
topicsDoneToday?: number;
|
||||
finishedProjectsCount?: number;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function DashboardStats(props: DashboardStatsProps) {
|
||||
const {
|
||||
accountStreak,
|
||||
topicsDoneToday = 0,
|
||||
finishedProjectsCount = 0,
|
||||
isLoading,
|
||||
profile,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="container mb-3 flex flex-col gap-4 pb-2 pt-6 text-sm text-slate-400 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex w-full flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PersonalProfileButton
|
||||
isLoading={isLoading}
|
||||
name={profile.name}
|
||||
username={profile.username}
|
||||
avatar={profile.avatar}
|
||||
/>
|
||||
<div className="hidden flex-wrap items-center gap-2 md:flex">
|
||||
<DashboardStatItem
|
||||
icon={Zap}
|
||||
iconClassName="text-yellow-500"
|
||||
value={accountStreak?.count || 0}
|
||||
label="day streak"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DashboardStatItem
|
||||
icon={ChartColumn}
|
||||
iconClassName="text-green-500"
|
||||
value={topicsDoneToday}
|
||||
label="learnt today"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<DashboardStatItem
|
||||
icon={FolderGit2}
|
||||
iconClassName="text-blue-500"
|
||||
value={finishedProjectsCount}
|
||||
label="projects finished"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
const {
|
||||
builtInRoleRoadmaps = [],
|
||||
builtInBestPractices = [],
|
||||
builtInSkillRoadmaps = [],
|
||||
questionGroups = [],
|
||||
guides = [],
|
||||
videos = [],
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [personalDashboardDetails, setPersonalDashboardDetails] =
|
||||
useState<UserDashboardResponse>();
|
||||
@@ -293,9 +138,7 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
const learningRoadmapsToShow: UserProgress[] = (
|
||||
personalDashboardDetails?.progresses || []
|
||||
)
|
||||
const learningRoadmapsToShow = (personalDashboardDetails?.progresses || [])
|
||||
.filter((progress) => !progress.isCustomResource)
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -313,10 +156,7 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
});
|
||||
|
||||
const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || [];
|
||||
|
||||
const customRoadmaps: UserProgress[] = (
|
||||
personalDashboardDetails?.progresses || []
|
||||
)
|
||||
const customRoadmaps = (personalDashboardDetails?.progresses || [])
|
||||
.filter((progress) => progress.isCustomResource)
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -329,6 +169,43 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const allRoadmapsAndBestPractices = [
|
||||
...builtInRoleRoadmaps,
|
||||
...builtInSkillRoadmaps,
|
||||
...builtInBestPractices,
|
||||
];
|
||||
|
||||
const relatedRoadmapIds = allRoadmapsAndBestPractices
|
||||
// take the ones that user is learning
|
||||
.filter((roadmap) =>
|
||||
learningRoadmapsToShow?.some(
|
||||
(learningRoadmap) => learningRoadmap.resourceId === roadmap.id,
|
||||
),
|
||||
)
|
||||
.flatMap((roadmap) => roadmap.relatedRoadmapIds)
|
||||
// remove the ones that user is already learning or has bookmarked
|
||||
.filter(
|
||||
(roadmapId) =>
|
||||
!learningRoadmapsToShow.some((lr) => lr.resourceId === roadmapId),
|
||||
);
|
||||
|
||||
const recommendedRoadmapIds = new Set(
|
||||
relatedRoadmapIds.length === 0
|
||||
? [
|
||||
'frontend',
|
||||
'backend',
|
||||
'devops',
|
||||
'ai-data-scientist',
|
||||
'full-stack',
|
||||
'api-design',
|
||||
]
|
||||
: relatedRoadmapIds,
|
||||
);
|
||||
|
||||
const recommendedRoadmaps = allRoadmapsAndBestPractices.filter((roadmap) =>
|
||||
recommendedRoadmapIds.has(roadmap.id),
|
||||
);
|
||||
|
||||
const enrichedProjects = personalDashboardDetails?.projects
|
||||
.map((project) => {
|
||||
const projectDetail = projectDetails.find(
|
||||
@@ -355,200 +232,165 @@ export function PersonalDashboard(props: PersonalDashboardProps) {
|
||||
const { username } = personalDashboardDetails || {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DashboardStats
|
||||
profile={{
|
||||
name,
|
||||
username,
|
||||
avatar: avatarLink,
|
||||
isLoading,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
accountStreak={accountStreak}
|
||||
topicsDoneToday={personalDashboardDetails?.topicDoneToday}
|
||||
finishedProjectsCount={
|
||||
enrichedProjects?.filter((p) => p.submittedAt && p.repositoryUrl)
|
||||
.length
|
||||
}
|
||||
/>
|
||||
<section>
|
||||
{isLoading ? (
|
||||
<div className="h-7 w-1/4 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-between gap-1 sm:flex-row sm:items-center">
|
||||
<h2 className="text-lg font-medium">
|
||||
Hi {name}, good {getCurrentPeriod()}!
|
||||
</h2>
|
||||
<a
|
||||
href="/home"
|
||||
className="rounded-full bg-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-300 hover:text-black"
|
||||
>
|
||||
Visit Homepage
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FavoriteRoadmaps
|
||||
progress={learningRoadmapsToShow}
|
||||
customRoadmaps={customRoadmaps}
|
||||
aiRoadmaps={aiGeneratedRoadmaps}
|
||||
<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}
|
||||
accountStreak={accountStreak}
|
||||
topicDoneToday={personalDashboardDetails?.topicDoneToday || 0}
|
||||
/>
|
||||
|
||||
<div className="bg-gradient-to-b from-slate-900 to-black pb-12">
|
||||
<div className="relative mt-6 border-t border-t-[#1e293c] pt-12">
|
||||
<div className="container">
|
||||
<h2
|
||||
id="role-based-roadmaps"
|
||||
className="text-md font-regular absolute -top-[17px] left-4 flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2"
|
||||
>
|
||||
Role Based Roadmaps
|
||||
</h2>
|
||||
<ListDashboardCustomProgress
|
||||
progresses={customRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 sm:px-0 lg:grid-cols-3">
|
||||
{builtInRoleRoadmaps.map((roadmap) => {
|
||||
const roadmapProgress = learningRoadmapsToShow.find(
|
||||
(lr) => lr.resourceId === roadmap.id,
|
||||
);
|
||||
<DashboardAiRoadmaps
|
||||
roadmaps={aiGeneratedRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
const percentageDone =
|
||||
(((roadmapProgress?.skipped || 0) +
|
||||
(roadmapProgress?.done || 0)) /
|
||||
(roadmapProgress?.total || 1)) *
|
||||
100;
|
||||
<RecommendedRoadmaps
|
||||
roadmaps={recommendedRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={roadmap.id}
|
||||
resourceId={roadmap.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={roadmap.title}
|
||||
isFavorite={roadmap.isFavorite}
|
||||
percentageDone={percentageDone}
|
||||
isNew={roadmap.isNew}
|
||||
url={`/${roadmap.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
type DashboardCardProps = {
|
||||
icon?: JSXElementConstructor<any>;
|
||||
imgUrl?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
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 (
|
||||
<div className={cn('relative overflow-hidden', className)}>
|
||||
<a
|
||||
href={href}
|
||||
className="flex flex-col rounded-lg border border-gray-300 bg-white hover:border-gray-400 hover:bg-gray-50"
|
||||
>
|
||||
{Icon && (
|
||||
<div className="px-4 pb-3 pt-4">
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
</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;
|
||||
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={roadmap.id}
|
||||
resourceId={roadmap.id}
|
||||
resourceType="roadmap"
|
||||
resourceTitle={roadmap.title}
|
||||
isFavorite={roadmap.isFavorite}
|
||||
percentageDone={percentageDone}
|
||||
isNew={roadmap.isNew}
|
||||
url={`/${roadmap.id}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardCardSkeleton() {
|
||||
return (
|
||||
<div className="h-[128px] animate-pulse rounded-lg border border-gray-300 bg-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/FeaturedGuides.astro
Normal file
47
src/components/FeaturedGuides.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
35
src/components/FeaturedVideos.astro
Normal file
35
src/components/FeaturedVideos.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
61
src/components/GuideListItem.astro
Normal file
61
src/components/GuideListItem.astro
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
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,229 +1,164 @@
|
||||
import {
|
||||
FolderKanban,
|
||||
MapIcon,
|
||||
Plus,
|
||||
Sparkle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Square,
|
||||
SquareCheckBig,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx';
|
||||
import type { UserProgress } from '../TeamProgress/TeamProgressPage.tsx';
|
||||
import { HeroProject } from './HeroProject';
|
||||
import { HeroRoadmap } from './HeroRoadmap';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx';
|
||||
import { HeroItemsGroup } from './HeroItemsGroup';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
|
||||
export type AIRoadmapType = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
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;
|
||||
};
|
||||
}[];
|
||||
|
||||
type FavoriteRoadmapsProps = {
|
||||
progress: UserProgress[];
|
||||
projects: (ProjectStatusDocument & {
|
||||
title: string;
|
||||
})[];
|
||||
customRoadmaps: UserProgress[];
|
||||
aiRoadmaps: AIRoadmapType[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
progressList.forEach((progress) => {
|
||||
const href =
|
||||
progress.resourceType === 'best-practice'
|
||||
? `/best-practices/${progress.resourceId}`
|
||||
: `/${progress.resourceId}`;
|
||||
const element = document.querySelector(`a[href="${href}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) {
|
||||
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props;
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId: progress.resourceId,
|
||||
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 percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
const progressBar: HTMLElement | null =
|
||||
element.querySelector('[data-progress]');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percentageDone}%`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ProgressResponse = UserProgressResponse;
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<ProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
const heroEl = document.getElementById('hero-text')!;
|
||||
if (!heroEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroEl.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
heroEl.parentElement?.removeChild(heroEl);
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`,
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(progressList);
|
||||
setIsLoading(false);
|
||||
showProgressContainer();
|
||||
|
||||
// render progress on featured items
|
||||
renderProgress(progressList);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('refresh-favorites', loadProgress);
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress?.length > 0;
|
||||
const customRoadmaps = progress?.filter(
|
||||
(p) => p.isCustomResource && !p.team?.name,
|
||||
);
|
||||
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] = [];
|
||||
}
|
||||
|
||||
const projectsToShow = [
|
||||
...inProgressProjects,
|
||||
...(showCompleted ? completedProjects : []),
|
||||
];
|
||||
acc[currTeam.name].push(curr);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{isCreatingCustomRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
onClose={() => {
|
||||
setIsCreatingCustomRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HeroItemsGroup
|
||||
icon={<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />}
|
||||
isLoading={isLoading}
|
||||
title="Your progress and bookmarks"
|
||||
isEmpty={!isLoading && progress.length === 0}
|
||||
emptyTitle={
|
||||
<>
|
||||
No bookmars found
|
||||
<a
|
||||
href="#role-based-roadmaps"
|
||||
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
<SquareCheckBig className="size-3.5" strokeWidth={2.5} />
|
||||
Bookmark a roadmap
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
<div
|
||||
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
|
||||
>
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
>
|
||||
{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 className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
teamRoadmaps={teamRoadmaps}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
264
src/components/HeroSection/HeroRoadmaps.tsx
Normal file
264
src/components/HeroSection/HeroRoadmaps.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
|
||||
type HeroTitleProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
title: string | ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
isEmpty?: boolean;
|
||||
emptyTitle?: ReactNode;
|
||||
};
|
||||
|
||||
export function HeroTitle(props: HeroTitleProps) {
|
||||
const {
|
||||
isLoading = false,
|
||||
title,
|
||||
icon,
|
||||
rightContent,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
isEmpty = false,
|
||||
emptyTitle,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="flex items-center gap-0.5 text-sm text-gray-400">
|
||||
{!isLoading && icon}
|
||||
{isLoading && (
|
||||
<span className="mr-1.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
{!isEmpty ? title : emptyTitle || title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isCollapsed && rightContent}
|
||||
|
||||
{!isLoading && !isEmpty && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className={cn(
|
||||
'ml-2 inline-flex items-center gap-1 rounded-md bg-slate-800 py-0.5 pl-1 pr-1.5 text-xs uppercase tracking-wider text-slate-400 hover:bg-slate-700',
|
||||
{
|
||||
'bg-slate-800 text-slate-500 hover:bg-slate-800 hover:text-slate-400':
|
||||
!isCollapsed,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" /> Expand
|
||||
</>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" /> Collapse
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
|
||||
import { SelectionButton } from './SelectionButton';
|
||||
import type { UserProgressResponse } from '../Roadmaps/RoadmapsPage';
|
||||
|
||||
type RoadmapSelectProps = {
|
||||
selectedRoadmaps: string[];
|
||||
|
||||
@@ -10,27 +10,8 @@ import {
|
||||
} from '../../lib/browser.ts';
|
||||
import { RoadmapCard } from './RoadmapCard.tsx';
|
||||
import { httpGet } from '../../lib/http.ts';
|
||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps.tsx';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
roadmapSlug?: string;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
role: AllowedMemberRoles;
|
||||
};
|
||||
}[];
|
||||
|
||||
const groupNames = [
|
||||
'Absolute Beginners',
|
||||
|
||||
@@ -18,6 +18,9 @@ import { pageProgressMessage } from '../../stores/page';
|
||||
import { MemberProgressModalHeader } from './MemberProgressModalHeader';
|
||||
import { replaceChildren } from '../../lib/dom.ts';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer.ts';
|
||||
import { getResourceMeta } from '../../lib/roadmap.ts';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
member: TeamMember;
|
||||
@@ -88,14 +91,24 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const page = await getResourceMeta(resourceType, resourceId);
|
||||
if (!page) {
|
||||
toast.error('Resource not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = page.renderer || 'balsamiq';
|
||||
|
||||
const res = await fetch(jsonUrl, {});
|
||||
const json = await res.json();
|
||||
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
const svg =
|
||||
renderer === 'editor'
|
||||
? await renderFlowJSON(json as any)
|
||||
: await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
replaceChildren(containerEl.current!, svg);
|
||||
// containerEl.current?.replaceChildren(svg);
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
@@ -136,10 +149,10 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
skipped = [],
|
||||
} = memberProgress;
|
||||
|
||||
done.forEach((id: string) => renderTopicProgress(id, 'done'));
|
||||
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
|
||||
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
|
||||
removed.forEach((id: string) => renderTopicProgress(id, 'removed'));
|
||||
done.forEach((id) => renderTopicProgress(id, 'done'));
|
||||
learning.forEach((id) => renderTopicProgress(id, 'learning'));
|
||||
skipped.forEach((id) => renderTopicProgress(id, 'skipped'));
|
||||
removed.forEach((id) => renderTopicProgress(id, 'removed'));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { MemberProgressItem } from './MemberProgressItem';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { GroupRoadmapItem } from './GroupRoadmapItem';
|
||||
import { MemberCustomProgressModal } from './MemberCustomProgressModal';
|
||||
import { MemberProgressItem } from './MemberProgressItem';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { MemberCustomProgressModal } from './MemberCustomProgressModal';
|
||||
import { canManageCurrentRoadmap } from '../../stores/roadmap.ts';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useAuth } from '../../hooks/use-auth';
|
||||
import { ModalLoader } from './ModalLoader.tsx';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
import { X } from 'lucide-react';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
|
||||
import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts';
|
||||
import { renderFlowJSON } from '../../../editor/renderer/renderer.ts';
|
||||
|
||||
|
||||
40
src/components/VideoListItem.astro
Normal file
40
src/components/VideoListItem.astro
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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>
|
||||
@@ -20,7 +20,7 @@ public:
|
||||
// Make a copy only if we want to modify the data.
|
||||
void write(const std::string &str) {
|
||||
// Check if there's more than one reference.
|
||||
if(!data.unique()) {
|
||||
if(data.use_count() > 1) {
|
||||
data = std::make_shared<std::string>(*data);
|
||||
std::cout << "Copy is actually made for writing." << std::endl;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import slugify from 'slugify';
|
||||
import { getAllAuthors, type AuthorFileType } from './author.ts';
|
||||
import { getAllGuides } from './guide.ts';
|
||||
|
||||
export interface RawQuestionGroupFrontmatter {
|
||||
interface RawQuestionGroupFrontmatter {
|
||||
order: number;
|
||||
briefTitle: string;
|
||||
briefDescription: string;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PageType } from '../components/CommandMenu/CommandMenu';
|
||||
import type { MarkdownFileType } from './file';
|
||||
import { httpGet } from './http';
|
||||
import type { ResourceType } from './resource-progress';
|
||||
|
||||
export function resourceTitleFromId(id: string): string {
|
||||
if (id === 'devops') {
|
||||
@@ -150,3 +153,29 @@ export async function getRoadmapFaqsById(roadmapId: string): Promise<string[]> {
|
||||
|
||||
return faqs || [];
|
||||
}
|
||||
|
||||
export async function getResourceMeta(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
) {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
if (error || !response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = response.find((page) => {
|
||||
if (resourceType === 'roadmap') {
|
||||
return page.url === `/${resourceId}`;
|
||||
} else if (resourceType === 'best-practice') {
|
||||
return page.url === `/best-practices/${resourceId}`;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
import AstroIcon from '../../components/AstroIcon.astro';
|
||||
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
|
||||
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
||||
import AstroIcon from '../../components/AstroIcon.astro';
|
||||
import { getGuidesByAuthor } from '../../lib/guide';
|
||||
import { getAllQuestionGroups } from '../../lib/question-group';
|
||||
import { getVideosByAuthor } from '../../lib/video';
|
||||
import GuideListItem from '../../components/GuideListItem.astro';
|
||||
import { getAuthorById, getAuthorIds } from '../../lib/author';
|
||||
import VideoListItem from '../../components/VideoListItem.astro';
|
||||
import { getAllQuestionGroups } from '../../lib/question-group';
|
||||
|
||||
interface Params extends Record<string, string | undefined> {}
|
||||
|
||||
@@ -136,12 +136,9 @@ const videos = await getVideosByAuthor(authorId);
|
||||
{
|
||||
[...guides, ...questionGuides]
|
||||
.sort((a, b) => {
|
||||
const aFrontmatter = a.frontmatter as any;
|
||||
const bFrontmatter = b.frontmatter as any;
|
||||
|
||||
const aDate = aFrontmatter.date || aFrontmatter.publishedAt;
|
||||
const bDate = bFrontmatter.date || bFrontmatter.publishedAt;
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
||||
const aDate = a.frontmatter.date || a.frontmatter.publishedAt;
|
||||
const bDate = b.frontmatter.date || b.frontmatter.publishedAt;
|
||||
return new Date(bDate) - new Date(aDate);
|
||||
})
|
||||
.map((guide) => <GuideListItem guide={guide} />)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
import { DashboardPage } from '../components/Dashboard/DashboardPage';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { getAllBestPractices } from '../lib/best-practice';
|
||||
import { getAllQuestionGroups } from '../lib/question-group';
|
||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||
import { getAllGuides } from '../lib/guide';
|
||||
import { getAllVideos } from '../lib/video';
|
||||
|
||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
||||
const bestPractices = await getAllBestPractices();
|
||||
const questionGroups = await getAllQuestionGroups();
|
||||
const guides = await getAllGuides();
|
||||
const videos = await getAllVideos();
|
||||
|
||||
const enrichedRoleRoadmaps = roleRoadmaps
|
||||
.filter((roadmapItem) => !roadmapItem.frontmatter.isHidden)
|
||||
@@ -26,7 +20,6 @@ const enrichedRoleRoadmaps = roleRoadmaps
|
||||
description: frontmatter.briefDescription,
|
||||
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
||||
renderer: frontmatter.renderer,
|
||||
isNew: frontmatter.isNew,
|
||||
metadata: {
|
||||
tags: frontmatter.tags,
|
||||
},
|
||||
@@ -45,7 +38,6 @@ const enrichedSkillRoadmaps = skillRoadmaps
|
||||
description: frontmatter.briefDescription,
|
||||
relatedRoadmapIds: frontmatter.relatedRoadmaps,
|
||||
renderer: frontmatter.renderer,
|
||||
isNew: frontmatter.isNew,
|
||||
metadata: {
|
||||
tags: frontmatter.tags,
|
||||
},
|
||||
@@ -64,16 +56,13 @@ const enrichedBestPractices = bestPractices.map((bestPractice) => {
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout title='Dashboard' noIndex={true} permalink='/dashboard'>
|
||||
<BaseLayout title='Dashboard' noIndex={true} permalink="/dashboard">
|
||||
<DashboardPage
|
||||
builtInRoleRoadmaps={enrichedRoleRoadmaps}
|
||||
builtInSkillRoadmaps={enrichedSkillRoadmaps}
|
||||
builtInBestPractices={enrichedBestPractices}
|
||||
questionGroups={questionGroups}
|
||||
guides={guides.slice(0, 7)}
|
||||
videos={videos.slice(0, 7)}
|
||||
client:load
|
||||
/>
|
||||
<div slot='open-source-banner'></div>
|
||||
<div slot='changelog-banner'></div>
|
||||
<div slot="changelog-banner" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem';
|
||||
import GuideListItem from '../../components/GuideListItem.astro';
|
||||
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllGuides } from '../../lib/guide';
|
||||
@@ -12,8 +12,8 @@ const questionGuides = (await getAllQuestionGroups()).filter(
|
||||
|
||||
const allGuides = [...guides, ...questionGuides];
|
||||
const sortedGuides = allGuides.sort((a, b) => {
|
||||
const aDate = new Date(a.frontmatter.date as string);
|
||||
const bDate = new Date(b.frontmatter.date as string);
|
||||
const aDate = new Date(a.frontmatter.date);
|
||||
const bDate = new Date(b.frontmatter.date);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
});
|
||||
@@ -36,5 +36,5 @@ const sortedGuides = allGuides.sort((a, b) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div slot='changelog-banner'></div>
|
||||
<div slot="changelog-banner" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
---
|
||||
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||
import { FeaturedGuideList } from '../components/FeaturedGuides/FeaturedGuideList';
|
||||
import FeaturedVideos from '../components/FeaturedVideos.astro';
|
||||
import FeaturedGuides from '../components/FeaturedGuides.astro';
|
||||
import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro';
|
||||
import { FeaturedVideoList } from '../components/FeaturedVideos/FeaturedVideoList';
|
||||
import HeroSection from '../components/HeroSection/HeroSection.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { getAllBestPractices } from '../lib/best-practice';
|
||||
import { getAllGuides } from '../lib/guide';
|
||||
import { getAllQuestionGroups } from '../lib/question-group';
|
||||
import { getRoadmapsByTag } from '../lib/roadmap';
|
||||
import { getAllVideos } from '../lib/video';
|
||||
import { getAllQuestionGroups } from '../lib/question-group';
|
||||
import ChangelogBanner from '../components/ChangelogBanner.astro';
|
||||
|
||||
const roleRoadmaps = await getRoadmapsByTag('role-roadmap');
|
||||
const skillRoadmaps = await getRoadmapsByTag('skill-roadmap');
|
||||
const bestPractices = await getAllBestPractices();
|
||||
const questionGroups = await getAllQuestionGroups();
|
||||
|
||||
export const projectGroups = [
|
||||
const projectGroups = [
|
||||
{
|
||||
title: 'Frontend',
|
||||
id: 'frontend',
|
||||
@@ -105,12 +104,12 @@ const videos = await getAllVideos();
|
||||
/>
|
||||
|
||||
<div class='grid grid-cols-1 gap-7 bg-gray-50 py-7 sm:gap-16 sm:py-16'>
|
||||
<FeaturedGuideList
|
||||
<FeaturedGuides
|
||||
heading='Guides'
|
||||
guides={guides.slice(0, 7)}
|
||||
questions={questionGuides.slice(0, 7)}
|
||||
/>
|
||||
<FeaturedVideoList heading='Videos' videos={videos.slice(0, 7)} />
|
||||
<FeaturedVideos heading='Videos' videos={videos.slice(0, 7)} />
|
||||
</div>
|
||||
</div>
|
||||
<ChangelogBanner slot='changelog-banner' />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import VideoListItem from '../../components/VideoListItem.astro';
|
||||
import SimplePageHeader from '../../components/SimplePageHeader.astro';
|
||||
import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllVideos } from '../../lib/video';
|
||||
|
||||
|
||||
@@ -128,24 +128,8 @@ a > code:before {
|
||||
animation: barberpole 15s linear infinite;
|
||||
}
|
||||
|
||||
.striped-loader-slate {
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 5px,
|
||||
hsla(0, 0%, 0%, 0.1) 5px,
|
||||
hsla(0, 0%, 0%, 0.1) 10px
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: barberpole 30s linear infinite;
|
||||
}
|
||||
|
||||
.striped-loader-slate-fast {
|
||||
animation: barberpole 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes barberpole {
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user