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 messagingpull/8190/head
parent
31a852113f
commit
203bbc6eae
28 changed files with 1214 additions and 915 deletions
@ -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> |
|
@ -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> |
||||||
|
); |
||||||
|
}
|
@ -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> |
|
@ -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> |
||||||
|
); |
||||||
|
} |
@ -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, |
||||||
export type UserProgressResponse = { |
Square, |
||||||
resourceId: string; |
SquareCheckBig, |
||||||
resourceType: 'roadmap' | 'best-practice'; |
} from 'lucide-react'; |
||||||
resourceTitle: string; |
import { useState } from 'react'; |
||||||
isFavorite: boolean; |
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx'; |
||||||
done: number; |
import { CheckIcon } from '../ReactIcons/CheckIcon.tsx'; |
||||||
learning: number; |
import type { UserProgress } from '../TeamProgress/TeamProgressPage.tsx'; |
||||||
skipped: number; |
import { HeroProject } from './HeroProject'; |
||||||
total: number; |
import { HeroRoadmap } from './HeroRoadmap'; |
||||||
updatedAt: Date; |
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx'; |
||||||
isCustomResource: boolean; |
import { HeroItemsGroup } from './HeroItemsGroup'; |
||||||
roadmapSlug?: string; |
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx'; |
||||||
team?: { |
|
||||||
name: string; |
export type AIRoadmapType = { |
||||||
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; |
}; |
||||||
} |
|
||||||
|
export function FavoriteRoadmaps(props: FavoriteRoadmapsProps) { |
||||||
window.dispatchEvent( |
const { progress, isLoading, customRoadmaps, aiRoadmaps, projects } = props; |
||||||
new CustomEvent('mark-favorite', { |
const [showCompleted, setShowCompleted] = useState(false); |
||||||
detail: { |
const [isCreatingCustomRoadmap, setIsCreatingCustomRoadmap] = useState(false); |
||||||
resourceId: progress.resourceId, |
|
||||||
resourceType: progress.resourceType, |
const completedProjects = projects.filter( |
||||||
isFavorite: progress.isFavorite, |
(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] = []; |
|
||||||
} |
|
||||||
|
|
||||||
acc[currTeam.name].push(curr); |
|
||||||
|
|
||||||
return acc; |
const projectsToShow = [ |
||||||
}, {}); |
...inProgressProjects, |
||||||
|
...(showCompleted ? completedProjects : []), |
||||||
|
]; |
||||||
|
|
||||||
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]` |
}} |
||||||
}`}
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
<HeroItemsGroup |
||||||
|
icon={<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />} |
||||||
|
isLoading={isLoading} |
||||||
|
title="Your progress and bookmarks" |
||||||
|
isEmpty={!isLoading && progress.length === 0} |
||||||
|
emptyTitle={ |
||||||
|
<> |
||||||
|
No bookmars found |
||||||
|
<a |
||||||
|
href="#role-based-roadmaps" |
||||||
|
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline" |
||||||
|
> |
||||||
|
<SquareCheckBig className="size-3.5" strokeWidth={2.5} /> |
||||||
|
Bookmark a roadmap |
||||||
|
</a> |
||||||
|
</> |
||||||
|
} |
||||||
|
> |
||||||
|
{progress.map((resource) => ( |
||||||
|
<HeroRoadmap |
||||||
|
key={`${resource.resourceType}-${resource.resourceId}`} |
||||||
|
resourceId={resource.resourceId} |
||||||
|
resourceType={resource.resourceType} |
||||||
|
resourceTitle={resource.resourceTitle} |
||||||
|
isFavorite={resource.isFavorite} |
||||||
|
percentageDone={ |
||||||
|
((resource.skipped + resource.done) / resource.total) * 100 |
||||||
|
} |
||||||
|
url={ |
||||||
|
resource.resourceType === 'roadmap' |
||||||
|
? `/${resource.resourceId}` |
||||||
|
: `/best-practices/${resource.resourceId}` |
||||||
|
} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</HeroItemsGroup> |
||||||
|
|
||||||
|
<HeroItemsGroup |
||||||
|
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />} |
||||||
|
isLoading={isLoading} |
||||||
|
title="Your custom roadmaps" |
||||||
|
isEmpty={!isLoading && customRoadmaps.length === 0} |
||||||
|
emptyTitle={ |
||||||
|
<> |
||||||
|
No custom roadmaps found |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
setIsCreatingCustomRoadmap(true); |
||||||
|
}} |
||||||
|
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline" |
||||||
|
> |
||||||
|
<SquareCheckBig className="size-3.5" strokeWidth={2.5} /> |
||||||
|
Create custom roadmap |
||||||
|
</button> |
||||||
|
</> |
||||||
|
} |
||||||
|
> |
||||||
|
{customRoadmaps.map((customRoadmap) => ( |
||||||
|
<HeroRoadmap |
||||||
|
key={customRoadmap.resourceId} |
||||||
|
resourceId={customRoadmap.resourceId} |
||||||
|
resourceType={'roadmap'} |
||||||
|
resourceTitle={customRoadmap.resourceTitle} |
||||||
|
percentageDone={ |
||||||
|
((customRoadmap.skipped + customRoadmap.done) / |
||||||
|
customRoadmap.total) * |
||||||
|
100 |
||||||
|
} |
||||||
|
url={`/r/${customRoadmap?.roadmapSlug}`} |
||||||
|
allowFavorite={false} |
||||||
|
/> |
||||||
|
))} |
||||||
|
<CreateRoadmapButton /> |
||||||
|
</HeroItemsGroup> |
||||||
|
|
||||||
|
<HeroItemsGroup |
||||||
|
icon={<Sparkle className="mr-1.5 h-[14px] w-[14px]" />} |
||||||
|
isLoading={isLoading} |
||||||
|
title="Your AI roadmaps" |
||||||
|
isEmpty={!isLoading && aiRoadmaps.length === 0} |
||||||
|
emptyTitle={ |
||||||
|
<> |
||||||
|
No AI roadmaps found |
||||||
|
<a |
||||||
|
href="/ai" |
||||||
|
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline" |
||||||
|
> |
||||||
|
<SquareCheckBig className="size-3.5" strokeWidth={2.5} /> |
||||||
|
Generate AI roadmap |
||||||
|
</a> |
||||||
|
</> |
||||||
|
} |
||||||
|
> |
||||||
|
{aiRoadmaps.map((aiRoadmap) => ( |
||||||
|
<HeroRoadmap |
||||||
|
key={aiRoadmap.id} |
||||||
|
resourceId={aiRoadmap.id} |
||||||
|
resourceType={'roadmap'} |
||||||
|
resourceTitle={aiRoadmap.title} |
||||||
|
url={`/ai/${aiRoadmap.slug}`} |
||||||
|
percentageDone={0} |
||||||
|
allowFavorite={false} |
||||||
|
isTrackable={false} |
||||||
|
/> |
||||||
|
))} |
||||||
|
|
||||||
|
<a |
||||||
|
href="/ai" |
||||||
|
className={ |
||||||
|
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300' |
||||||
|
} |
||||||
|
> |
||||||
|
<Plus size={16} /> |
||||||
|
Generate New |
||||||
|
</a> |
||||||
|
</HeroItemsGroup> |
||||||
|
|
||||||
|
<HeroItemsGroup |
||||||
|
icon={<FolderKanban className="mr-1.5 h-[14px] w-[14px]" />} |
||||||
|
isLoading={isLoading} |
||||||
|
title="Your active projects" |
||||||
|
isEmpty={!isLoading && projectsToShow.length === 0} |
||||||
|
emptyTitle={ |
||||||
|
<> |
||||||
|
No active projects found |
||||||
|
<a |
||||||
|
href="/projects" |
||||||
|
className="ml-1.5 inline-flex items-center gap-1 font-medium text-blue-500 underline-offset-2 hover:underline" |
||||||
|
> |
||||||
|
<SquareCheckBig className="size-3.5" strokeWidth={2.5} /> |
||||||
|
Start a new project |
||||||
|
</a> |
||||||
|
</> |
||||||
|
} |
||||||
|
rightContent={ |
||||||
|
completedProjects.length > 0 && ( |
||||||
|
<button |
||||||
|
onClick={() => setShowCompleted(!showCompleted)} |
||||||
|
className="hidden items-center gap-2 rounded-md text-xs text-slate-400 hover:text-slate-300 sm:flex" |
||||||
|
> |
||||||
|
{showCompleted ? ( |
||||||
|
<EyeOff className="h-3.5 w-3.5" /> |
||||||
|
) : ( |
||||||
|
<Eye className="h-3.5 w-3.5" /> |
||||||
|
)} |
||||||
|
{completedProjects.length} Completed |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
className="border-b-0" |
||||||
> |
> |
||||||
<div className="container min-h-full"> |
{projectsToShow.map((project) => ( |
||||||
{!isLoading && progress?.length == 0 && <EmptyProgress />} |
<HeroProject key={project._id} project={project} /> |
||||||
{hasProgress && ( |
))} |
||||||
<HeroRoadmaps |
|
||||||
teamRoadmaps={teamRoadmaps} |
<a |
||||||
customRoadmaps={customRoadmaps} |
href="/projects" |
||||||
progress={defaultRoadmaps} |
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" |
||||||
isLoading={isLoading} |
> |
||||||
/> |
<Plus size={16} /> |
||||||
)} |
Start a new project |
||||||
</div> |
</a> |
||||||
</div> |
</HeroItemsGroup> |
||||||
</div> |
</div> |
||||||
); |
); |
||||||
} |
} |
||||||
|
@ -0,0 +1,78 @@ |
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'; |
||||||
|
import { cn } from '../../lib/classname'; |
||||||
|
import { HeroTitle } from './HeroTitle'; |
||||||
|
|
||||||
|
type HeroItemsGroupProps = { |
||||||
|
icon: any; |
||||||
|
isLoading?: boolean; |
||||||
|
isEmpty?: boolean; |
||||||
|
emptyTitle?: ReactNode; |
||||||
|
title: string | ReactNode; |
||||||
|
rightContent?: ReactNode; |
||||||
|
children?: ReactNode; |
||||||
|
className?: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function HeroItemsGroup(props: HeroItemsGroupProps) { |
||||||
|
const { |
||||||
|
icon, |
||||||
|
isLoading = false, |
||||||
|
isEmpty = false, |
||||||
|
emptyTitle, |
||||||
|
title, |
||||||
|
rightContent, |
||||||
|
children, |
||||||
|
className, |
||||||
|
} = props; |
||||||
|
|
||||||
|
const storageKey = `hero-group-${title}-collapsed`; |
||||||
|
const [isCollapsed, setIsCollapsed] = useState(true); |
||||||
|
|
||||||
|
function isCollapsedByStorage() { |
||||||
|
const stored = localStorage.getItem(storageKey); |
||||||
|
|
||||||
|
return stored === 'true'; |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setIsCollapsed(isCollapsedByStorage()); |
||||||
|
}, [isLoading]); |
||||||
|
|
||||||
|
const isLoadingOrCollapsedOrEmpty = isLoading || isCollapsed || isEmpty; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'border-b border-gray-800/50', |
||||||
|
{ |
||||||
|
'py-4': !isLoadingOrCollapsedOrEmpty, |
||||||
|
'py-4 ': isLoadingOrCollapsedOrEmpty, |
||||||
|
'opacity-50 transition-opacity hover:opacity-100': |
||||||
|
isCollapsed && !isLoading, |
||||||
|
}, |
||||||
|
className, |
||||||
|
)} |
||||||
|
> |
||||||
|
<div className="container"> |
||||||
|
<HeroTitle |
||||||
|
icon={icon} |
||||||
|
isLoading={isLoading} |
||||||
|
isEmpty={isEmpty} |
||||||
|
emptyTitle={emptyTitle} |
||||||
|
title={title} |
||||||
|
rightContent={rightContent} |
||||||
|
isCollapsed={isCollapsed} |
||||||
|
onToggleCollapse={() => { |
||||||
|
setIsCollapsed(!isCollapsed); |
||||||
|
localStorage.setItem(storageKey, (!isCollapsed).toString()); |
||||||
|
}} |
||||||
|
/> |
||||||
|
{!isLoadingOrCollapsedOrEmpty && ( |
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3"> |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
import { ThumbsUp } from 'lucide-react'; |
||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
import { getRelativeTimeString } from '../../lib/date'; |
||||||
|
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions.tsx'; |
||||||
|
|
||||||
|
type HeroProjectProps = { |
||||||
|
project: ProjectStatusDocument & { |
||||||
|
title: string; |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
export function HeroProject({ project }: HeroProjectProps) { |
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={`/projects/${project.projectId}`} |
||||||
|
className="group relative flex flex-col justify-between gap-2 rounded-md border border-slate-800 bg-slate-900 p-3.5 hover:border-slate-600" |
||||||
|
> |
||||||
|
<div className="relative z-10 flex items-start justify-between gap-2"> |
||||||
|
<h3 className="truncate font-medium text-slate-300 group-hover:text-slate-100"> |
||||||
|
{project.title} |
||||||
|
</h3> |
||||||
|
<span |
||||||
|
className={cn( |
||||||
|
'absolute -right-2 -top-2 flex flex-shrink-0 items-center gap-1 rounded-full text-xs uppercase tracking-wide', |
||||||
|
{ |
||||||
|
'text-green-600/50': project.submittedAt && project.repositoryUrl, |
||||||
|
'text-yellow-600': !project.submittedAt || !project.repositoryUrl, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
{project.submittedAt && project.repositoryUrl ? 'Done' : ''} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div className="relative z-10 flex items-center gap-2 text-xs text-slate-400"> |
||||||
|
{project.submittedAt && project.repositoryUrl && ( |
||||||
|
<span className="flex items-center gap-1"> |
||||||
|
<ThumbsUp className="h-3 w-3" /> |
||||||
|
{project.upvotes} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{project.startedAt && ( |
||||||
|
<span>Started {getRelativeTimeString(project.startedAt)}</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-slate-800/50 via-transparent to-transparent" /> |
||||||
|
{project.submittedAt && project.repositoryUrl && ( |
||||||
|
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-green-950/20 via-transparent to-transparent" /> |
||||||
|
)} |
||||||
|
</a> |
||||||
|
); |
||||||
|
}
|
@ -0,0 +1,74 @@ |
|||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
import type { ResourceType } from '../../lib/resource-progress.ts'; |
||||||
|
import { MarkFavorite } from '../FeaturedItems/MarkFavorite.tsx'; |
||||||
|
|
||||||
|
type ProgressRoadmapProps = { |
||||||
|
url: string; |
||||||
|
percentageDone: number; |
||||||
|
allowFavorite?: boolean; |
||||||
|
|
||||||
|
resourceId: string; |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceTitle: string; |
||||||
|
isFavorite?: boolean; |
||||||
|
|
||||||
|
isTrackable?: boolean; |
||||||
|
isNew?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export function HeroRoadmap(props: ProgressRoadmapProps) { |
||||||
|
const { |
||||||
|
url, |
||||||
|
percentageDone, |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
resourceTitle, |
||||||
|
isFavorite, |
||||||
|
allowFavorite = true, |
||||||
|
isTrackable = true, |
||||||
|
isNew = false, |
||||||
|
} = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={url} |
||||||
|
className={cn( |
||||||
|
'relative flex flex-col overflow-hidden rounded-md border p-3 text-sm text-slate-400 hover:text-slate-300', |
||||||
|
{ |
||||||
|
'border-slate-800 bg-slate-900 hover:border-slate-600': isTrackable, |
||||||
|
'border-slate-700/50 bg-slate-800/50 hover:border-slate-600/70': |
||||||
|
!isTrackable, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
<span title={resourceTitle} className="relative z-20 truncate"> |
||||||
|
{resourceTitle} |
||||||
|
</span> |
||||||
|
|
||||||
|
{isTrackable && ( |
||||||
|
<span |
||||||
|
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]" |
||||||
|
style={{ width: `${percentageDone}%` }} |
||||||
|
></span> |
||||||
|
)} |
||||||
|
|
||||||
|
{allowFavorite && ( |
||||||
|
<MarkFavorite |
||||||
|
resourceId={resourceId} |
||||||
|
resourceType={resourceType} |
||||||
|
favorite={isFavorite} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{isNew && ( |
||||||
|
<span className="absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300"> |
||||||
|
<span className="mr-1.5 flex h-2 w-2"> |
||||||
|
<span className="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75" /> |
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-purple-500" /> |
||||||
|
</span> |
||||||
|
New |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</a> |
||||||
|
); |
||||||
|
}
|
@ -1,264 +0,0 @@ |
|||||||
import type { UserProgressResponse } from './FavoriteRoadmaps'; |
|
||||||
import { CheckIcon } from '../ReactIcons/CheckIcon'; |
|
||||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite'; |
|
||||||
import { Spinner } from '../ReactIcons/Spinner'; |
|
||||||
import type { ResourceType } from '../../lib/resource-progress'; |
|
||||||
import { MapIcon, Users2 } from 'lucide-react'; |
|
||||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton'; |
|
||||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal'; |
|
||||||
import { type ReactNode, useState } from 'react'; |
|
||||||
import { FeatureAnnouncement } from '../FeatureAnnouncement.tsx'; |
|
||||||
|
|
||||||
type ProgressRoadmapProps = { |
|
||||||
url: string; |
|
||||||
percentageDone: number; |
|
||||||
allowFavorite?: boolean; |
|
||||||
|
|
||||||
resourceId: string; |
|
||||||
resourceType: ResourceType; |
|
||||||
resourceTitle: string; |
|
||||||
isFavorite?: boolean; |
|
||||||
}; |
|
||||||
function HeroRoadmap(props: ProgressRoadmapProps) { |
|
||||||
const { |
|
||||||
url, |
|
||||||
percentageDone, |
|
||||||
resourceType, |
|
||||||
resourceId, |
|
||||||
resourceTitle, |
|
||||||
isFavorite, |
|
||||||
allowFavorite = true, |
|
||||||
} = props; |
|
||||||
|
|
||||||
return ( |
|
||||||
<a |
|
||||||
href={url} |
|
||||||
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300" |
|
||||||
> |
|
||||||
<span className="relative z-20">{resourceTitle}</span> |
|
||||||
|
|
||||||
<span |
|
||||||
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]" |
|
||||||
style={{ width: `${percentageDone}%` }} |
|
||||||
></span> |
|
||||||
|
|
||||||
{allowFavorite && ( |
|
||||||
<MarkFavorite |
|
||||||
resourceId={resourceId} |
|
||||||
resourceType={resourceType} |
|
||||||
favorite={isFavorite} |
|
||||||
/> |
|
||||||
)} |
|
||||||
</a> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
type ProgressTitleProps = { |
|
||||||
icon: any; |
|
||||||
isLoading?: boolean; |
|
||||||
title: string | ReactNode; |
|
||||||
}; |
|
||||||
|
|
||||||
export function HeroTitle(props: ProgressTitleProps) { |
|
||||||
const { isLoading = false, title, icon } = props; |
|
||||||
|
|
||||||
return ( |
|
||||||
<p className="mb-4 flex items-center text-sm text-gray-400"> |
|
||||||
{!isLoading && icon} |
|
||||||
{isLoading && ( |
|
||||||
<span className="mr-1.5"> |
|
||||||
<Spinner /> |
|
||||||
</span> |
|
||||||
)} |
|
||||||
{title} |
|
||||||
</p> |
|
||||||
); |
|
||||||
} |
|
||||||
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>; |
|
||||||
|
|
||||||
type ProgressListProps = { |
|
||||||
progress: UserProgressResponse; |
|
||||||
customRoadmaps: UserProgressResponse; |
|
||||||
teamRoadmaps?: HeroTeamRoadmaps; |
|
||||||
isLoading?: boolean; |
|
||||||
}; |
|
||||||
|
|
||||||
export function HeroRoadmaps(props: ProgressListProps) { |
|
||||||
const { |
|
||||||
teamRoadmaps = {}, |
|
||||||
progress, |
|
||||||
isLoading = false, |
|
||||||
customRoadmaps, |
|
||||||
} = props; |
|
||||||
|
|
||||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false); |
|
||||||
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>(); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="relative pb-12 pt-4 sm:pt-7"> |
|
||||||
<p className="mb-7 mt-2 text-sm"> |
|
||||||
<FeatureAnnouncement /> |
|
||||||
</p> |
|
||||||
{isCreatingRoadmap && ( |
|
||||||
<CreateRoadmapModal |
|
||||||
teamId={creatingRoadmapTeamId} |
|
||||||
onClose={() => { |
|
||||||
setIsCreatingRoadmap(false); |
|
||||||
setCreatingRoadmapTeamId(undefined); |
|
||||||
}} |
|
||||||
/> |
|
||||||
)} |
|
||||||
{ |
|
||||||
<HeroTitle |
|
||||||
icon={ |
|
||||||
(<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />) as any |
|
||||||
} |
|
||||||
isLoading={isLoading} |
|
||||||
title="Your progress and favorite roadmaps." |
|
||||||
/> |
|
||||||
} |
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
|
||||||
{progress.map((resource) => ( |
|
||||||
<HeroRoadmap |
|
||||||
key={`${resource.resourceType}-${resource.resourceId}`} |
|
||||||
resourceId={resource.resourceId} |
|
||||||
resourceType={resource.resourceType} |
|
||||||
resourceTitle={resource.resourceTitle} |
|
||||||
isFavorite={resource.isFavorite} |
|
||||||
percentageDone={ |
|
||||||
((resource.skipped + resource.done) / resource.total) * 100 |
|
||||||
} |
|
||||||
url={ |
|
||||||
resource.resourceType === 'roadmap' |
|
||||||
? `/${resource.resourceId}` |
|
||||||
: `/best-practices/${resource.resourceId}` |
|
||||||
} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
|
|
||||||
<div className="mt-5"> |
|
||||||
{ |
|
||||||
<HeroTitle |
|
||||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />} |
|
||||||
title="Your custom roadmaps" |
|
||||||
/> |
|
||||||
} |
|
||||||
|
|
||||||
{customRoadmaps.length === 0 && ( |
|
||||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600"> |
|
||||||
You haven't created any custom roadmaps yet.{' '} |
|
||||||
<button |
|
||||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400" |
|
||||||
onClick={() => setIsCreatingRoadmap(true)} |
|
||||||
> |
|
||||||
Create one! |
|
||||||
</button> |
|
||||||
</p> |
|
||||||
)} |
|
||||||
|
|
||||||
{customRoadmaps.length > 0 && ( |
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
|
||||||
{customRoadmaps.map((customRoadmap) => { |
|
||||||
return ( |
|
||||||
<HeroRoadmap |
|
||||||
key={customRoadmap.resourceId} |
|
||||||
resourceId={customRoadmap.resourceId} |
|
||||||
resourceType={'roadmap'} |
|
||||||
resourceTitle={customRoadmap.resourceTitle} |
|
||||||
percentageDone={ |
|
||||||
((customRoadmap.skipped + customRoadmap.done) / |
|
||||||
customRoadmap.total) * |
|
||||||
100 |
|
||||||
} |
|
||||||
url={`/r/${customRoadmap?.roadmapSlug}`} |
|
||||||
allowFavorite={false} |
|
||||||
/> |
|
||||||
); |
|
||||||
})} |
|
||||||
|
|
||||||
<CreateRoadmapButton /> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
|
|
||||||
{Object.keys(teamRoadmaps).map((teamName) => { |
|
||||||
const currentTeam: UserProgressResponse[0]['team'] = |
|
||||||
teamRoadmaps?.[teamName]?.[0]?.team; |
|
||||||
const roadmapsList = teamRoadmaps[teamName].filter( |
|
||||||
(roadmap) => !!roadmap.resourceTitle, |
|
||||||
); |
|
||||||
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="mt-5" key={teamName}> |
|
||||||
{ |
|
||||||
<HeroTitle |
|
||||||
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />} |
|
||||||
title={ |
|
||||||
<> |
|
||||||
Team{' '} |
|
||||||
<a |
|
||||||
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300" |
|
||||||
href={`/team/activity?t=${currentTeam?.id}`} |
|
||||||
> |
|
||||||
{teamName} |
|
||||||
</a> |
|
||||||
Roadmaps |
|
||||||
</> |
|
||||||
} |
|
||||||
/> |
|
||||||
} |
|
||||||
|
|
||||||
{roadmapsList.length === 0 && ( |
|
||||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600"> |
|
||||||
Team does not have any roadmaps yet.{' '} |
|
||||||
{canManageTeam && ( |
|
||||||
<button |
|
||||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400" |
|
||||||
onClick={() => { |
|
||||||
setCreatingRoadmapTeamId(currentTeam?.id); |
|
||||||
setIsCreatingRoadmap(true); |
|
||||||
}} |
|
||||||
> |
|
||||||
Create one! |
|
||||||
</button> |
|
||||||
)} |
|
||||||
</p> |
|
||||||
)} |
|
||||||
|
|
||||||
{roadmapsList.length > 0 && ( |
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3"> |
|
||||||
{roadmapsList.map((customRoadmap) => { |
|
||||||
return ( |
|
||||||
<HeroRoadmap |
|
||||||
key={customRoadmap.resourceId} |
|
||||||
resourceId={customRoadmap.resourceId} |
|
||||||
resourceType={'roadmap'} |
|
||||||
resourceTitle={customRoadmap.resourceTitle} |
|
||||||
percentageDone={ |
|
||||||
((customRoadmap.skipped + customRoadmap.done) / |
|
||||||
customRoadmap.total) * |
|
||||||
100 |
|
||||||
} |
|
||||||
url={`/r/${customRoadmap?.roadmapSlug}`} |
|
||||||
allowFavorite={false} |
|
||||||
/> |
|
||||||
); |
|
||||||
})} |
|
||||||
|
|
||||||
{canManageTeam && ( |
|
||||||
<CreateRoadmapButton |
|
||||||
teamId={currentTeam?.id} |
|
||||||
text="Create Team Roadmap" |
|
||||||
/> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
); |
|
||||||
})} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -0,0 +1,71 @@ |
|||||||
|
import type { ReactNode } from 'react'; |
||||||
|
import { Spinner } from '../ReactIcons/Spinner.tsx'; |
||||||
|
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'; |
||||||
|
import { cn } from '../../lib/classname.ts'; |
||||||
|
|
||||||
|
type HeroTitleProps = { |
||||||
|
icon: any; |
||||||
|
isLoading?: boolean; |
||||||
|
title: string | ReactNode; |
||||||
|
rightContent?: ReactNode; |
||||||
|
isCollapsed?: boolean; |
||||||
|
onToggleCollapse?: () => void; |
||||||
|
isEmpty?: boolean; |
||||||
|
emptyTitle?: ReactNode; |
||||||
|
}; |
||||||
|
|
||||||
|
export function HeroTitle(props: HeroTitleProps) { |
||||||
|
const { |
||||||
|
isLoading = false, |
||||||
|
title, |
||||||
|
icon, |
||||||
|
rightContent, |
||||||
|
isCollapsed = false, |
||||||
|
onToggleCollapse, |
||||||
|
isEmpty = false, |
||||||
|
emptyTitle, |
||||||
|
} = props; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<div className="flex items-center gap-3"> |
||||||
|
<p className="flex items-center gap-0.5 text-sm text-gray-400"> |
||||||
|
{!isLoading && icon} |
||||||
|
{isLoading && ( |
||||||
|
<span className="mr-1.5"> |
||||||
|
<Spinner /> |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{!isEmpty ? title : emptyTitle || title} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
{!isCollapsed && rightContent} |
||||||
|
|
||||||
|
{!isLoading && !isEmpty && ( |
||||||
|
<button |
||||||
|
onClick={onToggleCollapse} |
||||||
|
className={cn( |
||||||
|
'ml-2 inline-flex items-center gap-1 rounded-md bg-slate-800 py-0.5 pl-1 pr-1.5 text-xs uppercase tracking-wider text-slate-400 hover:bg-slate-700', |
||||||
|
{ |
||||||
|
'bg-slate-800 text-slate-500 hover:bg-slate-800 hover:text-slate-400': |
||||||
|
!isCollapsed, |
||||||
|
}, |
||||||
|
)} |
||||||
|
> |
||||||
|
{isCollapsed && ( |
||||||
|
<> |
||||||
|
<ChevronsUpDown className="h-3.5 w-3.5" /> Expand |
||||||
|
</> |
||||||
|
)} |
||||||
|
{!isCollapsed && ( |
||||||
|
<> |
||||||
|
<ChevronsDownUp className="h-3.5 w-3.5" /> Collapse |
||||||
|
</> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -1,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> |
|
Loading…
Reference in new issue