diff --git a/.astro/settings.json b/.astro/settings.json index 9aa9d76f8..50ff25a6a 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -3,6 +3,6 @@ "enabled": false }, "_variables": { - "lastUpdateCheck": 1738019390029 + "lastUpdateCheck": 1739229597159 } } \ No newline at end of file diff --git a/src/components/Dashboard/DashboardPage.tsx b/src/components/Dashboard/DashboardPage.tsx index b7d832983..4c2cfee20 100644 --- a/src/components/Dashboard/DashboardPage.tsx +++ b/src/components/Dashboard/DashboardPage.tsx @@ -1,20 +1,27 @@ +import { useStore } from '@nanostores/react'; import { useEffect, useState } from 'react'; -import { httpGet } from '../../lib/http'; +import { cn } from '../../../editor/utils/classname'; +import { useParams } from '../../hooks/use-params'; import { useToast } from '../../hooks/use-toast'; -import { useStore } from '@nanostores/react'; +import { httpGet } from '../../lib/http'; +import { getUser } from '../../lib/jwt'; import { $teamList } from '../../stores/team'; import type { TeamListResponse } from '../TeamDropdown/TeamDropdown'; -import { DashboardTab } from './DashboardTab'; +import { DashboardTabButton } from './DashboardTabButton'; import { PersonalDashboard, type BuiltInRoadmap } from './PersonalDashboard'; import { TeamDashboard } from './TeamDashboard'; -import { getUser } from '../../lib/jwt'; -import { useParams } from '../../hooks/use-params'; +import type { QuestionGroupType } from '../../lib/question-group'; +import type { GuideFileType } from '../../lib/guide'; +import type { VideoFileType } from '../../lib/video'; type DashboardPageProps = { builtInRoleRoadmaps?: BuiltInRoadmap[]; builtInSkillRoadmaps?: BuiltInRoadmap[]; builtInBestPractices?: BuiltInRoadmap[]; isTeamPage?: boolean; + questionGroups?: QuestionGroupType[]; + guides?: GuideFileType[]; + videos?: VideoFileType[]; }; export function DashboardPage(props: DashboardPageProps) { @@ -23,6 +30,9 @@ export function DashboardPage(props: DashboardPageProps) { builtInBestPractices, builtInSkillRoadmaps, isTeamPage = false, + questionGroups, + guides, + videos, } = props; const currentUser = getUser(); @@ -66,78 +76,80 @@ export function DashboardPage(props: DashboardPageProps) { : '/images/default-avatar.png'; return ( -
-
-
- - - {isLoading && ( - <> - - - - )} - - {!isLoading && ( - <> - {teamList.map((team) => { - const { avatar } = team; - const avatarUrl = avatar - ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` - : '/images/default-avatar.png'; - return ( - - ); - })} - - - )} + <> +
+
+
+ + + {!isLoading && ( + <> + {teamList.map((team) => { + const { avatar } = team; + const avatarUrl = avatar + ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` + : '/images/default-avatar.png'; + return ( + + ); + })} + + + )} +
+
+
{!selectedTeamId && !isTeamPage && ( - +
+ +
)} {(selectedTeamId || isTeamPage) && ( - +
+ +
)}
-
- ); -} - -function DashboardTabSkeleton() { - return ( -
+ ); } diff --git a/src/components/Dashboard/DashboardTab.tsx b/src/components/Dashboard/DashboardTabButton.tsx similarity index 66% rename from src/components/Dashboard/DashboardTab.tsx rename to src/components/Dashboard/DashboardTabButton.tsx index c6b345599..2daa41c84 100644 --- a/src/components/Dashboard/DashboardTab.tsx +++ b/src/components/Dashboard/DashboardTabButton.tsx @@ -11,7 +11,7 @@ type DashboardTabProps = { icon?: ReactNode; }; -export function DashboardTab(props: DashboardTabProps) { +export function DashboardTabButton(props: DashboardTabProps) { const { isActive, onClick, label, className, href, avatar, icon } = props; const Slot = href ? 'a' : 'button'; @@ -20,8 +20,10 @@ export function DashboardTab(props: DashboardTabProps) { )} {icon} diff --git a/src/components/Dashboard/PersonalDashboard.tsx b/src/components/Dashboard/PersonalDashboard.tsx index eb23d7c98..ebc417577 100644 --- a/src/components/Dashboard/PersonalDashboard.tsx +++ b/src/components/Dashboard/PersonalDashboard.tsx @@ -1,23 +1,35 @@ -import { type JSXElementConstructor, useEffect, useState } from 'react'; -import { httpGet } from '../../lib/http'; -import type { UserProgress } from '../TeamProgress/TeamProgressPage'; -import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; -import type { PageType } from '../CommandMenu/CommandMenu'; -import { useToast } from '../../hooks/use-toast'; -import { getCurrentPeriod } from '../../lib/date'; -import { ListDashboardCustomProgress } from './ListDashboardCustomProgress'; -import { RecommendedRoadmaps } from './RecommendedRoadmaps'; -import { ProgressStack } from './ProgressStack'; import { useStore } from '@nanostores/react'; -import { $accountStreak, type StreakResponse } from '../../stores/streak'; -import { CheckEmoji } from '../ReactIcons/CheckEmoji.tsx'; -import { ConstructionEmoji } from '../ReactIcons/ConstructionEmoji.tsx'; -import { BookEmoji } from '../ReactIcons/BookEmoji.tsx'; -import { DashboardAiRoadmaps } from './DashboardAiRoadmaps.tsx'; +import { + ChartColumn, + CheckCircle, + CheckSquare, + FolderGit2, + Pencil, + SquarePen, + Zap, + type LucideIcon, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; import type { AllowedProfileVisibility } from '../../api/user.ts'; -import { PencilIcon, type LucideIcon } from 'lucide-react'; +import { useToast } from '../../hooks/use-toast'; import { cn } from '../../lib/classname.ts'; +import { httpGet } from '../../lib/http'; import type { AllowedRoadmapRenderer } from '../../lib/roadmap.ts'; +import { $accountStreak, type StreakResponse } from '../../stores/streak'; +import type { PageType } from '../CommandMenu/CommandMenu'; +import { + FavoriteRoadmaps, + type AIRoadmapType, +} from '../HeroSection/FavoriteRoadmaps.tsx'; +import { HeroRoadmap } from '../HeroSection/HeroRoadmap.tsx'; +import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions'; +import type { UserProgress } from '../TeamProgress/TeamProgressPage'; +import { projectGroups } from '../../pages/index.astro'; +import type { QuestionGroupType } from '../../lib/question-group'; +import { FeaturedGuideList } from '../FeaturedGuides/FeaturedGuideList'; +import { FeaturedVideoList } from '../FeaturedVideos/FeaturedVideoList'; +import type { GuideFileType } from '../../lib/guide'; +import type { VideoFileType } from '../../lib/video'; type UserDashboardResponse = { name: string; @@ -28,11 +40,7 @@ type UserDashboardResponse = { profileVisibility: AllowedProfileVisibility; progresses: UserProgress[]; projects: ProjectStatusDocument[]; - aiRoadmaps: { - id: string; - title: string; - slug: string; - }[]; + aiRoadmaps: AIRoadmapType[]; topicDoneToday: number; }; @@ -42,6 +50,7 @@ export type BuiltInRoadmap = { title: string; description: string; isFavorite?: boolean; + isNew?: boolean; relatedRoadmapIds?: string[]; renderer?: AllowedRoadmapRenderer; metadata?: Record; @@ -51,16 +60,162 @@ type PersonalDashboardProps = { builtInRoleRoadmaps?: BuiltInRoadmap[]; builtInSkillRoadmaps?: BuiltInRoadmap[]; builtInBestPractices?: BuiltInRoadmap[]; + questionGroups?: QuestionGroupType[]; + guides?: GuideFileType[]; + videos?: VideoFileType[]; +}; + +type DashboardStatItemProps = { + icon: LucideIcon; + iconClassName: string; + value: number; + label: string; + isLoading: boolean; +}; + +function DashboardStatItem(props: DashboardStatItemProps) { + const { icon: Icon, iconClassName, value, label, isLoading } = props; + + return ( +
+ + + {value} {label} + +
+ ); +} + +type ProfileButtonProps = { + isLoading: boolean; + name?: string; + username?: string; + avatar?: string; +}; + +function PersonalProfileButton(props: ProfileButtonProps) { + const { isLoading, name, username, avatar } = props; + + if (isLoading || !username) { + return ( + + + Set up your profile + + ); + } + + return ( +
+ + {name + Visit Profile + + + + +
+ ); +} + +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 ( +
+
+ +
+ + + +
+
+
+ ); +} + 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(); @@ -138,7 +293,9 @@ export function PersonalDashboard(props: PersonalDashboardProps) { return () => window.removeEventListener('refresh-favorites', loadProgress); }, []); - const learningRoadmapsToShow = (personalDashboardDetails?.progresses || []) + const learningRoadmapsToShow: UserProgress[] = ( + personalDashboardDetails?.progresses || [] + ) .filter((progress) => !progress.isCustomResource) .sort((a, b) => { const updatedAtA = new Date(a.updatedAt); @@ -156,7 +313,10 @@ export function PersonalDashboard(props: PersonalDashboardProps) { }); const aiGeneratedRoadmaps = personalDashboardDetails?.aiRoadmaps || []; - const customRoadmaps = (personalDashboardDetails?.progresses || []) + + const customRoadmaps: UserProgress[] = ( + personalDashboardDetails?.progresses || [] + ) .filter((progress) => progress.isCustomResource) .sort((a, b) => { const updatedAtA = new Date(a.updatedAt); @@ -169,43 +329,6 @@ export function PersonalDashboard(props: PersonalDashboardProps) { ? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}` : '/images/default-avatar.png'; - const allRoadmapsAndBestPractices = [ - ...builtInRoleRoadmaps, - ...builtInSkillRoadmaps, - ...builtInBestPractices, - ]; - - const relatedRoadmapIds = allRoadmapsAndBestPractices - // take the ones that user is learning - .filter((roadmap) => - learningRoadmapsToShow?.some( - (learningRoadmap) => learningRoadmap.resourceId === roadmap.id, - ), - ) - .flatMap((roadmap) => roadmap.relatedRoadmapIds) - // remove the ones that user is already learning or has bookmarked - .filter( - (roadmapId) => - !learningRoadmapsToShow.some((lr) => lr.resourceId === roadmapId), - ); - - const recommendedRoadmapIds = new Set( - relatedRoadmapIds.length === 0 - ? [ - 'frontend', - 'backend', - 'devops', - 'ai-data-scientist', - 'full-stack', - 'api-design', - ] - : relatedRoadmapIds, - ); - - const recommendedRoadmaps = allRoadmapsAndBestPractices.filter((roadmap) => - recommendedRoadmapIds.has(roadmap.id), - ); - const enrichedProjects = personalDashboardDetails?.projects .map((project) => { const projectDetail = projectDetails.find( @@ -232,165 +355,200 @@ export function PersonalDashboard(props: PersonalDashboardProps) { const { username } = personalDashboardDetails || {}; return ( -
- {isLoading ? ( -
- ) : ( -
-

- Hi {name}, good {getCurrentPeriod()}! -

- - Visit Homepage - -
- )} - -
- {isLoading ? ( - <> - - - - - - ) : ( - <> - - - - - - - - )} -
- - + - - - - p.submittedAt && p.repositoryUrl) + .length + } /> - -
- ); -} -type DashboardCardProps = { - icon?: JSXElementConstructor; - imgUrl?: string; - title: string; - description: string; - href: string; - externalLinkIcon?: LucideIcon; - externalLinkText?: string; - externalLinkHref?: string; - className?: string; -}; +
+
+
+

+ Role Based Roadmaps +

+ +
+ {builtInRoleRoadmaps.map((roadmap) => { + const roadmapProgress = learningRoadmapsToShow.find( + (lr) => lr.resourceId === roadmap.id, + ); + + const percentageDone = + (((roadmapProgress?.skipped || 0) + + (roadmapProgress?.done || 0)) / + (roadmapProgress?.total || 1)) * + 100; + + return ( + + ); + })} +
+
+
-function DashboardCard(props: DashboardCardProps) { - const { - icon: Icon, - imgUrl, - title, - description, - href, - externalLinkHref, - externalLinkIcon: ExternalLinkIcon, - externalLinkText, - className, - } = props; +
+
+

+ Skill Based Roadmaps +

+ +
+ {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 ( + + ); + })} +
+
+
- return ( -
- - {Icon && ( -
- +
+
+

+ Project Ideas +

+ +
+ {projectGroups.map((projectGroup) => { + return ( + + ); + })} +
- )} +
- {imgUrl && ( -
); } - -function DashboardCardSkeleton() { - return ( -
- ); -} diff --git a/src/components/FeaturedGuides.astro b/src/components/FeaturedGuides.astro deleted file mode 100644 index 3d78a90b8..000000000 --- a/src/components/FeaturedGuides.astro +++ /dev/null @@ -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(); -}); ---- - -
-

{heading}

- -
- {sortedGuides.map((guide) => )} -
- - - - -
diff --git a/src/components/FeaturedGuides/FeaturedGuideList.tsx b/src/components/FeaturedGuides/FeaturedGuideList.tsx new file mode 100644 index 000000000..15427f2d9 --- /dev/null +++ b/src/components/FeaturedGuides/FeaturedGuideList.tsx @@ -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 ( +
+

{heading}

+ +
+ {sortedGuides.map((guide) => ( + + ))} +
+ + + View All Guides → + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/FeaturedGuides/GuideListItem.tsx b/src/components/FeaturedGuides/GuideListItem.tsx new file mode 100644 index 000000000..70cafebce --- /dev/null +++ b/src/components/FeaturedGuides/GuideListItem.tsx @@ -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 ( + + + {frontmatter.title} + + {frontmatter.isNew && ( + + New + +  ·  + {new Date(frontmatter.date || '').toLocaleString('default', { + month: 'long', + })} + + + )} + + + {guideType} + + + » + + ); +} diff --git a/src/components/FeaturedVideos.astro b/src/components/FeaturedVideos.astro deleted file mode 100644 index dbd70b792..000000000 --- a/src/components/FeaturedVideos.astro +++ /dev/null @@ -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; ---- - -
-

{heading}

- -
- {videos.map((video) => )} -
- - - - -
\ No newline at end of file diff --git a/src/components/FeaturedVideos/FeaturedVideoList.tsx b/src/components/FeaturedVideos/FeaturedVideoList.tsx new file mode 100644 index 000000000..4001c8241 --- /dev/null +++ b/src/components/FeaturedVideos/FeaturedVideoList.tsx @@ -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 ( +
+

{heading}

+ +
+ {videos.map((video) => ( + + ))} +
+ + + View All Videos → + + + +
+ ); +} diff --git a/src/components/FeaturedVideos/VideoListItem.tsx b/src/components/FeaturedVideos/VideoListItem.tsx new file mode 100644 index 000000000..31aa4bd67 --- /dev/null +++ b/src/components/FeaturedVideos/VideoListItem.tsx @@ -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 ( + + + {frontmatter.title} + + {frontmatter.isNew && ( + + New + + · + {new Date(frontmatter.date).toLocaleString('default', { + month: 'long', + })} + + + )} + + + {frontmatter.duration} + + + » + + ); +} \ No newline at end of file diff --git a/src/components/GuideListItem.astro b/src/components/GuideListItem.astro deleted file mode 100644 index a862ad354..000000000 --- a/src/components/GuideListItem.astro +++ /dev/null @@ -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; -} ---- - - - - {frontmatter.title} - - { - frontmatter.isNew && ( - - New - - - ) - } - - - - » - diff --git a/src/components/HeroSection/FavoriteRoadmaps.tsx b/src/components/HeroSection/FavoriteRoadmaps.tsx index a639be626..d2c585518 100644 --- a/src/components/HeroSection/FavoriteRoadmaps.tsx +++ b/src/components/HeroSection/FavoriteRoadmaps.tsx @@ -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([]); - 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( - `${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 ( -
-
+ {isCreatingCustomRoadmap && ( + { + setIsCreatingCustomRoadmap(false); + }} + /> + )} + + } + isLoading={isLoading} + title="Your progress and bookmarks" + isEmpty={!isLoading && progress.length === 0} + emptyTitle={ + <> + No bookmars found + + + Bookmark a roadmap + + + } + > + {progress.map((resource) => ( + + ))} + + + } + isLoading={isLoading} + title="Your custom roadmaps" + isEmpty={!isLoading && customRoadmaps.length === 0} + emptyTitle={ + <> + No custom roadmaps found + + + } + > + {customRoadmaps.map((customRoadmap) => ( + + ))} + + + + } + isLoading={isLoading} + title="Your AI roadmaps" + isEmpty={!isLoading && aiRoadmaps.length === 0} + emptyTitle={ + <> + No AI roadmaps found + + + Generate AI roadmap + + + } + > + {aiRoadmaps.map((aiRoadmap) => ( + + ))} + + + + Generate New + + + + } + isLoading={isLoading} + title="Your active projects" + isEmpty={!isLoading && projectsToShow.length === 0} + emptyTitle={ + <> + No active projects found + + + Start a new project + + + } + rightContent={ + completedProjects.length > 0 && ( + + ) + } + className="border-b-0" > -
- {!isLoading && progress?.length == 0 && } - {hasProgress && ( - - )} -
-
+ {projectsToShow.map((project) => ( + + ))} + + + + Start a new project + +
); } diff --git a/src/components/HeroSection/HeroItemsGroup.tsx b/src/components/HeroSection/HeroItemsGroup.tsx new file mode 100644 index 000000000..f4e9b46f5 --- /dev/null +++ b/src/components/HeroSection/HeroItemsGroup.tsx @@ -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 ( +
+
+ { + setIsCollapsed(!isCollapsed); + localStorage.setItem(storageKey, (!isCollapsed).toString()); + }} + /> + {!isLoadingOrCollapsedOrEmpty && ( +
+ {children} +
+ )} +
+
+ ); +} diff --git a/src/components/HeroSection/HeroProject.tsx b/src/components/HeroSection/HeroProject.tsx new file mode 100644 index 000000000..c86f08076 --- /dev/null +++ b/src/components/HeroSection/HeroProject.tsx @@ -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 ( + +
+

+ {project.title} +

+ + {project.submittedAt && project.repositoryUrl ? 'Done' : ''} + +
+
+ {project.submittedAt && project.repositoryUrl && ( + + + {project.upvotes} + + )} + {project.startedAt && ( + Started {getRelativeTimeString(project.startedAt)} + )} +
+ +
+ {project.submittedAt && project.repositoryUrl && ( +
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/HeroSection/HeroRoadmap.tsx b/src/components/HeroSection/HeroRoadmap.tsx new file mode 100644 index 000000000..c8f15ad2e --- /dev/null +++ b/src/components/HeroSection/HeroRoadmap.tsx @@ -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 ( + + + {resourceTitle} + + + {isTrackable && ( + + )} + + {allowFavorite && ( + + )} + + {isNew && ( + + + + + + New + + )} + + ); +} \ No newline at end of file diff --git a/src/components/HeroSection/HeroRoadmaps.tsx b/src/components/HeroSection/HeroRoadmaps.tsx deleted file mode 100644 index 7633f0ab7..000000000 --- a/src/components/HeroSection/HeroRoadmaps.tsx +++ /dev/null @@ -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 ( - - {resourceTitle} - - - - {allowFavorite && ( - - )} - - ); -} - -type ProgressTitleProps = { - icon: any; - isLoading?: boolean; - title: string | ReactNode; -}; - -export function HeroTitle(props: ProgressTitleProps) { - const { isLoading = false, title, icon } = props; - - return ( -

- {!isLoading && icon} - {isLoading && ( - - - - )} - {title} -

- ); -} -export type HeroTeamRoadmaps = Record; - -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(); - - return ( -
-

- -

- {isCreatingRoadmap && ( - { - setIsCreatingRoadmap(false); - setCreatingRoadmapTeamId(undefined); - }} - /> - )} - { - ) as any - } - isLoading={isLoading} - title="Your progress and favorite roadmaps." - /> - } - -
- {progress.map((resource) => ( - - ))} -
- -
- { - } - title="Your custom roadmaps" - /> - } - - {customRoadmaps.length === 0 && ( -

- You haven't created any custom roadmaps yet.{' '} - -

- )} - - {customRoadmaps.length > 0 && ( -
- {customRoadmaps.map((customRoadmap) => { - return ( - - ); - })} - - -
- )} -
- - {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 ( -
- { - } - title={ - <> - Team{' '} - - {teamName} - - Roadmaps - - } - /> - } - - {roadmapsList.length === 0 && ( -

- Team does not have any roadmaps yet.{' '} - {canManageTeam && ( - - )} -

- )} - - {roadmapsList.length > 0 && ( -
- {roadmapsList.map((customRoadmap) => { - return ( - - ); - })} - - {canManageTeam && ( - - )} -
- )} -
- ); - })} -
- ); -} diff --git a/src/components/HeroSection/HeroTitle.tsx b/src/components/HeroSection/HeroTitle.tsx new file mode 100644 index 000000000..7b2173f27 --- /dev/null +++ b/src/components/HeroSection/HeroTitle.tsx @@ -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 ( +
+
+

+ {!isLoading && icon} + {isLoading && ( + + + + )} + {!isEmpty ? title : emptyTitle || title} +

+
+
+ {!isCollapsed && rightContent} + + {!isLoading && !isEmpty && ( + + )} +
+
+ ); +} diff --git a/src/components/RoadCard/RoadmapSelect.tsx b/src/components/RoadCard/RoadmapSelect.tsx index 2db7bc2e4..baa7fae22 100644 --- a/src/components/RoadCard/RoadmapSelect.tsx +++ b/src/components/RoadCard/RoadmapSelect.tsx @@ -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[]; diff --git a/src/components/Roadmaps/RoadmapsPage.tsx b/src/components/Roadmaps/RoadmapsPage.tsx index defe3e3e8..91b6a72e1 100644 --- a/src/components/Roadmaps/RoadmapsPage.tsx +++ b/src/components/Roadmaps/RoadmapsPage.tsx @@ -10,8 +10,27 @@ import { } from '../../lib/browser.ts'; import { RoadmapCard } from './RoadmapCard.tsx'; import { httpGet } from '../../lib/http.ts'; -import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps.tsx'; import { isLoggedIn } from '../../lib/jwt.ts'; +import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx'; + +export type UserProgressResponse = { + resourceId: string; + resourceType: 'roadmap' | 'best-practice'; + resourceTitle: string; + isFavorite: boolean; + done: number; + learning: number; + skipped: number; + total: number; + updatedAt: Date; + isCustomResource: boolean; + roadmapSlug?: string; + team?: { + name: string; + id: string; + role: AllowedMemberRoles; + }; +}[]; const groupNames = [ 'Absolute Beginners', diff --git a/src/components/TeamProgress/TeamProgressPage.tsx b/src/components/TeamProgress/TeamProgressPage.tsx index 78e8c7585..74784f959 100644 --- a/src/components/TeamProgress/TeamProgressPage.tsx +++ b/src/components/TeamProgress/TeamProgressPage.tsx @@ -1,16 +1,15 @@ +import { useStore } from '@nanostores/react'; import { useEffect, useState } from 'react'; +import { useAuth } from '../../hooks/use-auth'; +import { useToast } from '../../hooks/use-toast'; +import { getUrlParams, setUrlParams } from '../../lib/browser'; import { httpGet } from '../../lib/http'; import { pageProgressMessage } from '../../stores/page'; -import { MemberProgressItem } from './MemberProgressItem'; -import { useToast } from '../../hooks/use-toast'; -import { useStore } from '@nanostores/react'; import { $currentTeam } from '../../stores/team'; import { GroupRoadmapItem } from './GroupRoadmapItem'; -import { getUrlParams, setUrlParams } from '../../lib/browser'; -import { useAuth } from '../../hooks/use-auth'; -import { MemberProgressModal } from './MemberProgressModal'; import { MemberCustomProgressModal } from './MemberCustomProgressModal'; -import { canManageCurrentRoadmap } from '../../stores/roadmap.ts'; +import { MemberProgressItem } from './MemberProgressItem'; +import { MemberProgressModal } from './MemberProgressModal'; export type UserProgress = { resourceTitle: string; diff --git a/src/components/VideoListItem.astro b/src/components/VideoListItem.astro deleted file mode 100644 index a95bf4905..000000000 --- a/src/components/VideoListItem.astro +++ /dev/null @@ -1,40 +0,0 @@ ---- -import type { VideoFileType } from '../lib/video'; - -export interface Props { - video: VideoFileType; -} - -const { video } = Astro.props; -const { frontmatter, id } = video; ---- - - - - {frontmatter.title} - - { - frontmatter.isNew && ( - - New - - - ) - } - - - - » - diff --git a/src/lib/question-group.ts b/src/lib/question-group.ts index 68950ed4c..57080cb11 100644 --- a/src/lib/question-group.ts +++ b/src/lib/question-group.ts @@ -3,7 +3,7 @@ import slugify from 'slugify'; import { getAllAuthors, type AuthorFileType } from './author.ts'; import { getAllGuides } from './guide.ts'; -interface RawQuestionGroupFrontmatter { +export interface RawQuestionGroupFrontmatter { order: number; briefTitle: string; briefDescription: string; diff --git a/src/pages/authors/[authorId].astro b/src/pages/authors/[authorId].astro index 0ed281319..2bbcd920d 100644 --- a/src/pages/authors/[authorId].astro +++ b/src/pages/authors/[authorId].astro @@ -1,12 +1,12 @@ --- -import BaseLayout from '../../layouts/BaseLayout.astro'; import AstroIcon from '../../components/AstroIcon.astro'; -import { getGuidesByAuthor } from '../../lib/guide'; -import { getVideosByAuthor } from '../../lib/video'; -import GuideListItem from '../../components/GuideListItem.astro'; +import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem'; +import { VideoListItem } from '../../components/FeaturedVideos/VideoListItem'; +import BaseLayout from '../../layouts/BaseLayout.astro'; import { getAuthorById, getAuthorIds } from '../../lib/author'; -import VideoListItem from '../../components/VideoListItem.astro'; +import { getGuidesByAuthor } from '../../lib/guide'; import { getAllQuestionGroups } from '../../lib/question-group'; +import { getVideosByAuthor } from '../../lib/video'; interface Params extends Record {} @@ -136,9 +136,12 @@ const videos = await getVideosByAuthor(authorId); { [...guides, ...questionGuides] .sort((a, b) => { - const aDate = a.frontmatter.date || a.frontmatter.publishedAt; - const bDate = b.frontmatter.date || b.frontmatter.publishedAt; - return new Date(bDate) - new Date(aDate); + const aFrontmatter = a.frontmatter as any; + const bFrontmatter = b.frontmatter as any; + + const aDate = aFrontmatter.date || aFrontmatter.publishedAt; + const bDate = bFrontmatter.date || bFrontmatter.publishedAt; + return new Date(bDate).getTime() - new Date(aDate).getTime(); }) .map((guide) => ) } diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 50d8afcce..618550470 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -2,11 +2,17 @@ import { DashboardPage } from '../components/Dashboard/DashboardPage'; import BaseLayout from '../layouts/BaseLayout.astro'; import { getAllBestPractices } from '../lib/best-practice'; +import { getAllQuestionGroups } from '../lib/question-group'; import { getRoadmapsByTag } from '../lib/roadmap'; +import { getAllGuides } from '../lib/guide'; +import { getAllVideos } from '../lib/video'; const roleRoadmaps = await getRoadmapsByTag('role-roadmap'); const skillRoadmaps = await getRoadmapsByTag('skill-roadmap'); const bestPractices = await getAllBestPractices(); +const questionGroups = await getAllQuestionGroups(); +const guides = await getAllGuides(); +const videos = await getAllVideos(); const enrichedRoleRoadmaps = roleRoadmaps .filter((roadmapItem) => !roadmapItem.frontmatter.isHidden) @@ -20,6 +26,7 @@ const enrichedRoleRoadmaps = roleRoadmaps description: frontmatter.briefDescription, relatedRoadmapIds: frontmatter.relatedRoadmaps, renderer: frontmatter.renderer, + isNew: frontmatter.isNew, metadata: { tags: frontmatter.tags, }, @@ -38,6 +45,7 @@ const enrichedSkillRoadmaps = skillRoadmaps description: frontmatter.briefDescription, relatedRoadmapIds: frontmatter.relatedRoadmaps, renderer: frontmatter.renderer, + isNew: frontmatter.isNew, metadata: { tags: frontmatter.tags, }, @@ -56,13 +64,16 @@ const enrichedBestPractices = bestPractices.map((bestPractice) => { }); --- - +
-
+
diff --git a/src/pages/guides/index.astro b/src/pages/guides/index.astro index df27054ff..ac7eeb02e 100644 --- a/src/pages/guides/index.astro +++ b/src/pages/guides/index.astro @@ -1,5 +1,5 @@ --- -import GuideListItem from '../../components/GuideListItem.astro'; +import { GuideListItem } from '../../components/FeaturedGuides/GuideListItem'; import SimplePageHeader from '../../components/SimplePageHeader.astro'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { getAllGuides } from '../../lib/guide'; @@ -12,8 +12,8 @@ const questionGuides = (await getAllQuestionGroups()).filter( const allGuides = [...guides, ...questionGuides]; const sortedGuides = allGuides.sort((a, b) => { - const aDate = new Date(a.frontmatter.date); - const bDate = new Date(b.frontmatter.date); + const aDate = new Date(a.frontmatter.date as string); + const bDate = new Date(b.frontmatter.date as string); return bDate.getTime() - aDate.getTime(); }); @@ -36,5 +36,5 @@ const sortedGuides = allGuides.sort((a, b) => {
-
+
diff --git a/src/pages/index.astro b/src/pages/index.astro index 23c83f0f8..bf92f689c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,21 +1,22 @@ --- -import FeaturedVideos from '../components/FeaturedVideos.astro'; -import FeaturedGuides from '../components/FeaturedGuides.astro'; +import ChangelogBanner from '../components/ChangelogBanner.astro'; +import { FeaturedGuideList } from '../components/FeaturedGuides/FeaturedGuideList'; import FeaturedItems from '../components/FeaturedItems/FeaturedItems.astro'; +import { FeaturedVideoList } from '../components/FeaturedVideos/FeaturedVideoList'; import HeroSection from '../components/HeroSection/HeroSection.astro'; import BaseLayout from '../layouts/BaseLayout.astro'; import { getAllBestPractices } from '../lib/best-practice'; import { getAllGuides } from '../lib/guide'; +import { getAllQuestionGroups } from '../lib/question-group'; import { getRoadmapsByTag } from '../lib/roadmap'; import { getAllVideos } from '../lib/video'; -import { getAllQuestionGroups } from '../lib/question-group'; -import ChangelogBanner from '../components/ChangelogBanner.astro'; const roleRoadmaps = await getRoadmapsByTag('role-roadmap'); const skillRoadmaps = await getRoadmapsByTag('skill-roadmap'); const bestPractices = await getAllBestPractices(); const questionGroups = await getAllQuestionGroups(); -const projectGroups = [ + +export const projectGroups = [ { title: 'Frontend', id: 'frontend', @@ -104,12 +105,12 @@ const videos = await getAllVideos(); />
- - +
diff --git a/src/pages/videos/index.astro b/src/pages/videos/index.astro index 4226e4444..5bfdd5b44 100644 --- a/src/pages/videos/index.astro +++ b/src/pages/videos/index.astro @@ -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'; diff --git a/src/styles/global.css b/src/styles/global.css index ab2e901cb..0a0fcc262 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -128,8 +128,24 @@ a > code:before { animation: barberpole 15s linear infinite; } +.striped-loader-slate { + background-image: repeating-linear-gradient( + -45deg, + transparent, + transparent 5px, + hsla(0, 0%, 0%, 0.1) 5px, + hsla(0, 0%, 0%, 0.1) 10px + ); + background-size: 200% 200%; + animation: barberpole 30s linear infinite; +} + +.striped-loader-slate-fast { + animation: barberpole 10s linear infinite; +} + @keyframes barberpole { 100% { background-position: 100% 100%; } -} \ No newline at end of file +}