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