diff --git a/docusaurus.config.js b/docusaurus.config.js index 87180cc7e..8b31ebf16 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -27,39 +27,39 @@ const config = { projectName: "codeharborhub.github.io", i18n: { - defaultLocale: 'en', - locales: ['en'], + defaultLocale: "en", + locales: ["en"], localeConfigs: { en: { - label: 'English (US)', - htmlLang: 'en-US', // Signals US English target to search engines + label: "English (US)", + htmlLang: "en-US", // Signals US English target to search engines }, }, }, headTags: [ { - tagName: 'meta', + tagName: "meta", attributes: { - name: 'impact-site-verification', - value: '0ecab9f1-a456-4638-9f7a-17e0ff5f55f6', + name: "impact-site-verification", + value: "0ecab9f1-a456-4638-9f7a-17e0ff5f55f6", }, }, { - tagName: 'script', + tagName: "script", attributes: { - type: 'application/ld+json', + type: "application/ld+json", }, innerHTML: JSON.stringify({ - '@context': 'https://schema.org', - '@type': 'Organization', - name: 'CodeHarborHub', - url: 'https://codeharborhub.github.io', - logo: 'https://codeharborhub.github.io/img/logo.png', + "@context": "https://schema.org", + "@type": "Organization", + name: "CodeHarborHub", + url: "https://codeharborhub.github.io", + logo: "https://codeharborhub.github.io/img/logo.png", sameAs: [ - 'https://github.com/CodeHarborHub', - 'https://twitter.com/CodeHarborHub', - 'https://www.linkedin.com/company/codeharborhub', + "https://github.com/CodeHarborHub", + "https://twitter.com/CodeHarborHub", + "https://www.linkedin.com/company/codeharborhub", ], }), }, @@ -262,87 +262,150 @@ const config = { }, ], }, + + // ===================================================== + // 2. PRACTICE + // ===================================================== { - to: "/github-stars-nominate", - html: '⭐ GitHub Stars', - position: "left", - }, - { - to: "/community", - html: '🀝 Community', + type: "dropdown", + label: "🧠 Practice", position: "left", - }, - { - to: "/our-sponsors/", - html: 'πŸ’° Donate', - }, - { - to: "https://codeharborhub.github.io/blog/", - html: 'πŸ“° Blog', - }, - { - to: "/feature-requests/", - html: 'πŸ’‘ Feature Requests', + items: [ + { + label: "🧩 Data Structures & Algorithms", + to: "https://codeharborhub.github.io/dsa/", + }, + { + label: "πŸ’‘ DSA Solutions", + to: "https://codeharborhub.github.io/dsa/solutions/", + }, + { + label: "πŸ§ͺ Coding Problems", + to: "https://codeharborhub.github.io/dsa/problems/", + }, + { + type: "html", + value: + 'CODING TOOLS', + }, + { + label: "⚑ Live Code Editor", + to: "/LiveEditor/", + }, + // { + // label: "πŸ† Coding Challenges", + // to: "/challenges/", + // }, + // { + // label: "❓ Quizzes", + // to: "/quizzes/", + // }, + // { + // label: "🎯 Daily Practice", + // to: "/daily-challenges/", + // }, + ], }, + // ===================================================== + // 3. BUILD + // ===================================================== { type: "dropdown", - html: 'πŸ”— More', + label: "πŸ› οΈ Build", position: "left", items: [ { - to: "/showcase", - label: "Showcase", + label: "πŸš€ Projects", + to: "/projects/", }, { - label: "Projects", - to: "/projects/", + label: "🌟 Project Showcase", + to: "/showcase/", + }, + // { + // label: "πŸ’» Open Source", + // to: "/open-source/", + // }, + { + label: "🀝 Contribute on GitHub", + to: "https://github.com/CodeHarborHub", }, { - label: "E-books", + label: "πŸ“š E-books", to: "/ebooks/", }, + // { + // label: "🧰 Developer Resources", + // to: "/resources/", + // }, + ], + }, + // ===================================================== + // 4. COMMUNITY + // ===================================================== + { + type: "dropdown", + label: "🌍 Community", + position: "left", + items: [ { - label: "Roadmap", - to: "/roadmap/", + label: "🀝 Join Community", + to: "/community/", }, { - label: "Live Editor", - to: "/LiveEditor/", + label: "πŸ“… Events & Workshops", + to: "/events/", + }, + { + label: "πŸ“° Blog & Articles", + to: "https://codeharborhub.github.io/blog/", }, { - label: "Broadcast", + label: "πŸ“’ Broadcast", to: "https://codeharborhub-broadcast-web.vercel.app/", }, { - label: "Events", - to: "/events/", + label: "πŸ’‘ Feature Requests", + to: "/feature-requests/", + }, + { + label: "πŸ› Report an Issue", + to: "https://github.com/CodeHarborHub/codeharborhub.github.io/issues", + }, + { + label: "⭐ GitHub Stars", + to: "/github-stars-nominate/", + }, + ], + }, + // ===================================================== + // 5. SUPPORT + // ===================================================== + { + type: "dropdown", + label: "❀️ Support", + position: "left", + items: [ + { + label: "πŸ’° Donate", + to: "/our-sponsors/", + }, + { + label: "🌟 Our Sponsors", + to: "/our-sponsors/", + }, + { + label: "β˜• Buy Me a Coffee", + to: "https://buymeacoffee.com/ajaydhangar", + }, + { + label: "⭐ Star on GitHub", + to: "https://github.com/CodeHarborHub/codeharborhub.github.io", }, ], }, - // { - // type: "dropdown", - // html: '🏷️ Tags', - // position: "left", - // items: [ - // { - // label: "🏷️ Tutorial Tags πŸ“š", - // to: "/docs/tags/", - // activeBaseRegex: "/docs/tags/", - // }, - // { - // label: "🏷️ Courses Tags πŸŽ“", - // to: "/courses/tags/", - // activeBaseRegex: "/courses/tags/", - // }, - // { - // label: "🏷️ DSA Tags 🧠", - // to: "/dsa/tags/", - // activeBaseRegex: "/dsa/tags/", - // }, - // ], - // }, { type: "search", position: "right", @@ -353,13 +416,7 @@ const config = { // className: "header-github-link", // "aria-label": "GitHub repository", // }, - // { - // href: "https://www.codeharborhub.live/register", - // position: "right", - // className: "header-signup-link", - // "aria-label": "Auth", - // label: "Auth", - // }, + { type: "custom-authButton", position: "right" }, ], hideOnScroll: true, }, diff --git a/src/components/AuthNavbarItem/index.tsx b/src/components/AuthNavbarItem/index.tsx new file mode 100644 index 000000000..09c7760a6 --- /dev/null +++ b/src/components/AuthNavbarItem/index.tsx @@ -0,0 +1,354 @@ + +import React, { useEffect, useRef, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +type AuthUser = { + name?: string; + email?: string; +}; + +export default function AuthNavbarItem() { + const { user, logout } = useAuth(); + + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + const authUser = user as AuthUser | null; + + const displayName = authUser?.name?.trim() || "Member"; + const email = authUser?.email || ""; + + const initials = displayName + .split(/\s+/) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join(""); + + // Close dropdown when clicking outside. + useEffect(() => { + function handleOutsideClick(event: MouseEvent) { + if ( + menuRef.current && + !menuRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("keydown", handleEscape); + }; + }, []); + + function handleLogout() { + setIsOpen(false); + + try { + logout(); + window.location.assign("/auth/login"); + } catch (error) { + console.error("Unable to log out:", error); + } + } + + const linkStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "10px", + padding: "11px 14px", + borderRadius: "10px", + color: "var(--ifm-font-color-base)", + textDecoration: "none", + fontSize: "0.9rem", + fontWeight: 500, + transition: "background 0.2s ease", + }; + + // Logged-out navbar + if (!user) { + return ( +
+ + Log in + + + + Get started + + +
+ ); + } + + // Logged-in navbar + return ( +
+ + + {isOpen && ( +
+ {/* User information */} +
+
+ {initials || "C"} +
+ +
+
+ {displayName} +
+ +
+ {email} +
+
+
+ + {/* Navigation links */} + setIsOpen(false)} + > + β–¦ + Dashboard + + + setIsOpen(false)} + > + πŸ‘€ + My profile + + + setIsOpen(false)} + > + βš™οΈ + Account settings + + + setIsOpen(false)} + > + πŸ” + Security & privacy + + + setIsOpen(false)} + > + πŸ“š + Explore tutorials + + + {/* Logout */} +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/auth/AuthNotice.tsx b/src/components/auth/AuthNotice.tsx new file mode 100644 index 000000000..ee9085a44 --- /dev/null +++ b/src/components/auth/AuthNotice.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +export default function AuthNotice({ + type = "info", + children, +}: { + type?: "info" | "success" | "error"; + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..6f976a52b --- /dev/null +++ b/src/components/auth/AuthShell.tsx @@ -0,0 +1,138 @@ + +import React from "react"; +// @ts-ignore +import Layout from "@theme/Layout"; +// @ts-ignore +import Link from "@docusaurus/Link"; + +interface AuthShellProps { + title: string; + subtitle: string; + children: React.ReactNode; + footer?: React.ReactNode; + wide?: boolean; +} + +export default function AuthShell({ + title, + subtitle, + children, + footer, + wide = false, +}: AuthShellProps) { + return ( + +
+ + {/* Background decoration */} +
+
+ ); +} diff --git a/src/components/auth/DashboardLayout.tsx b/src/components/auth/DashboardLayout.tsx new file mode 100644 index 000000000..615c9c69e --- /dev/null +++ b/src/components/auth/DashboardLayout.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useState } from "react"; +// @ts-ignore +import Layout from "@theme/Layout"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +type DashboardLayoutProps = { + title: string; + eyebrow?: string; + children: React.ReactNode; +}; + +const navigation = [ + { label: "Overview", href: "/dashboard", icon: "βŒ‚" }, + { label: "My profile", href: "/profile", icon: "β—Ž" }, + { label: "Account", href: "/settings/account", icon: "βš™" }, + { label: "Security", href: "/settings/security", icon: "β—‡" }, +]; + +function getInitials(name?: string) { + if (!name?.trim()) return "CH"; + + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join(""); +} + +function getGreeting(hour: number) { + if (hour < 12) return "Good morning"; + if (hour < 17) return "Good afternoon"; + return "Good evening"; +} + +export default function DashboardLayout({ + title, + eyebrow = "CODEHARBORHUB WORKSPACE", + children, +}: DashboardLayoutProps) { + const { user, logout } = useAuth(); + + const [now, setNow] = useState(() => new Date()); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [signingOut, setSigningOut] = useState(false); + + useEffect(() => { + const timer = window.setInterval(() => { + setNow(new Date()); + }, 1000); + + return () => window.clearInterval(timer); + }, []); + + const displayName = user?.name?.trim() || "Developer"; + const initials = getInitials(user?.name); + const greeting = getGreeting(now.getHours()); + + function signOut() { + if (signingOut) return; + + setSigningOut(true); + + try { + logout(); + window.location.assign("/auth/login"); + } catch { + setSigningOut(false); + } + } + + return ( + +
+ {/* Workspace background decoration */} +
+
+ ); +} diff --git a/src/components/auth/GuestRoute.tsx b/src/components/auth/GuestRoute.tsx new file mode 100644 index 000000000..6fb774246 --- /dev/null +++ b/src/components/auth/GuestRoute.tsx @@ -0,0 +1,22 @@ +import React, { useEffect } from "react"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +export default function GuestRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + + useEffect(() => { + if (!loading && user) window.location.replace("/dashboard"); + }, [loading, user]); + + if (loading || user) { + return ( +
+ +

{user ? "Taking you to your dashboard…" : "Loading…"}

+
+ ); + } + + return <>{children}; +} diff --git a/src/components/auth/PasswordField.tsx b/src/components/auth/PasswordField.tsx new file mode 100644 index 000000000..b04a44db7 --- /dev/null +++ b/src/components/auth/PasswordField.tsx @@ -0,0 +1,47 @@ + +import React, { useState } from "react"; + +interface PasswordFieldProps { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoComplete?: string; +} + +export default function PasswordField({ + label, + value, + onChange, + placeholder = "Enter your password", + autoComplete = "current-password", +}: PasswordFieldProps) { + const [visible, setVisible] = useState(false); + + return ( + + ); +} diff --git a/src/components/auth/PasswordInput.tsx b/src/components/auth/PasswordInput.tsx new file mode 100644 index 000000000..fa9c52804 --- /dev/null +++ b/src/components/auth/PasswordInput.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; + +interface PasswordInputProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + autoComplete?: string; + placeholder?: string; + required?: boolean; + hint?: string; +} + +export default function PasswordInput({ + id, + label, + value, + onChange, + autoComplete = "current-password", + placeholder = "Enter password", + required = true, + hint, +}: PasswordInputProps) { + const [visible, setVisible] = useState(false); + + return ( +
+ +
+ onChange(event.target.value)} + autoComplete={autoComplete} + placeholder={placeholder} + minLength={8} + maxLength={128} + required={required} + /> + +
+ {hint ? {hint} : null} +
+ ); +} diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 000000000..589377f8c --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; +// @ts-ignore +import { useLocation } from "@docusaurus/router"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +export default function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + useEffect(() => { + if (!loading && !user) { + const next = encodeURIComponent(location.pathname + location.search); + window.location.replace(`/auth/login?next=${next}`); + } + }, [loading, user, location.pathname, location.search]); + + if (loading) { + return ( +
+ +

Checking your demo session…

+
+ ); + } + + if (!user) { + return ( +
+

Sign in to access this page.

+ Go to sign in +
+ ); + } + + return <>{children}; +} diff --git a/src/components/learning/BookmarkButton.tsx b/src/components/learning/BookmarkButton.tsx new file mode 100644 index 000000000..1d309c168 --- /dev/null +++ b/src/components/learning/BookmarkButton.tsx @@ -0,0 +1,65 @@ + +import React from "react"; +// @ts-ignore +import { useLearning } from "@site/src/context/LearningContext"; +// @ts-ignore +import type { BookmarkType } from "@site/src/types/learning"; + +interface BookmarkButtonProps { + id: string; + title: string; + url: string; + type?: BookmarkType; + description?: string; + compact?: boolean; +} + +export default function BookmarkButton({ + id, + title, + url, + type = "tutorial", + description, + compact = false, +}: BookmarkButtonProps) { + const { isBookmarked, toggleBookmark } = useLearning(); + + const saved = isBookmarked(id); + + function handleClick() { + toggleBookmark({ + id, + title, + url, + type, + description, + }); + } + + return ( + + ); +} diff --git a/src/components/learning/DailyGoalCard.tsx b/src/components/learning/DailyGoalCard.tsx new file mode 100644 index 000000000..b2d7201f9 --- /dev/null +++ b/src/components/learning/DailyGoalCard.tsx @@ -0,0 +1,68 @@ + +import React from "react"; +// @ts-ignore +import { useLearning } from "@site/src/context/LearningContext"; +// @ts-ignore +import ProgressBar from "@site/src/components/learning/ProgressBar"; + +export default function DailyGoalCard() { + const { learning, setDailyGoal, isReady } = useLearning(); + + if (!isReady) return null; + + const completed = learning.dailyCompleted; + const goal = learning.dailyGoal; + const remaining = Math.max(0, goal - completed); + + return ( +
+
+
+ + DAILY LEARNING GOAL + + +

+ Make today count +

+
+ + 🎯 +
+ +
+

+ {completed} + + {" "}/ {goal} + +

+ + + {remaining === 0 ? "Goal completed!" : `${remaining} activities left`} + +
+ + + +
+ + Choose your daily target + + + +
+
+ ); +} diff --git a/src/components/learning/LessonCompletionButton.tsx b/src/components/learning/LessonCompletionButton.tsx new file mode 100644 index 000000000..c5e265009 --- /dev/null +++ b/src/components/learning/LessonCompletionButton.tsx @@ -0,0 +1,68 @@ + +import React from "react"; +// @ts-ignore +import { useLearning } from "@site/src/context/LearningContext"; + +interface LessonCompletionButtonProps { + id: string; + title: string; + courseId: string; + courseTitle: string; + xp?: number; +} + +export default function LessonCompletionButton({ + id, + title, + courseId, + courseTitle, + xp = 20, +}: LessonCompletionButtonProps) { + const { learning, completeLesson, isReady } = useLearning(); + + const completed = learning.completedLessons.some( + (lesson: any) => lesson.id === id, + ); + + if (!isReady) { + return ( + + Loading lesson progress... + + ); + } + + return ( +
+
+
+

+ {completed ? "Lesson completed!" : "Finished this lesson?"} +

+ +

+ {completed + ? "Your learning progress has been updated." + : `Complete this lesson to earn ${xp} XP.`} +

+
+ + +
+
+ ); +} diff --git a/src/components/learning/ProgressBar.tsx b/src/components/learning/ProgressBar.tsx new file mode 100644 index 000000000..347b49922 --- /dev/null +++ b/src/components/learning/ProgressBar.tsx @@ -0,0 +1,58 @@ + +import React from "react"; + +interface ProgressBarProps { + value: number; + max?: number; + label?: string; + showPercentage?: boolean; +} + +export default function ProgressBar({ + value, + max = 100, + label, + showPercentage = true, +}: ProgressBarProps) { + const percentage = + max > 0 + ? Math.min(100, Math.max(0, (value / max) * 100)) + : 0; + + return ( +
+ {(label || showPercentage) && ( +
+ {label && ( + + {label} + + )} + + {showPercentage && ( + + {Math.round(percentage)}% + + )} +
+ )} + +
+
+
+
+ ); +} diff --git a/src/components/learning/XPBadge.tsx b/src/components/learning/XPBadge.tsx new file mode 100644 index 000000000..eba38c5a3 --- /dev/null +++ b/src/components/learning/XPBadge.tsx @@ -0,0 +1,56 @@ + +import React from "react"; +// @ts-ignore +import { useLearning } from "@site/src/context/LearningContext"; + +export default function XPBadge() { + const { learning, isReady } = useLearning(); + + if (!isReady) return null; + + const xpInLevel = learning.xp % 100; + const nextLevelXP = 100 - xpInLevel; + + return ( +
+
+
+ + YOUR LEARNING LEVEL + + +

+ Level {learning.level} +

+ +

+ {learning.xp} total XP +

+
+ + πŸ† +
+ +
+
+ Level progress + {xpInLevel}/100 XP +
+ +
+
+
+ +

+ Earn {nextLevelXP} more XP to reach level {learning.level + 1}. +

+
+
+ ); +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 000000000..15c6adcfc --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,91 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +// @ts-ignore +import { authService, PublicUser } from "@site/src/services/authService"; + +interface AuthContextValue { + user: PublicUser | null; + loading: boolean; + isAuthenticated: boolean; + register: (name: string, email: string, password: string) => Promise; + login: (email: string, password: string) => Promise; + logout: () => void; + updateProfile: (name: string) => void; + refresh: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(() => { + setUser(authService.getCurrentUser()); + }, []); + + useEffect(() => { + refresh(); + setLoading(false); + + // Keep multiple tabs in sync when LocalStorage changes. + const onStorage = (event: StorageEvent) => { + if ( + event.key === "chh_demo_v1_session" || + event.key === "chh_demo_v1_users" || + event.key === null + ) { + refresh(); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, [refresh]); + + const register = useCallback(async (name: string, email: string, password: string) => { + const result = await authService.register(name, email, password); + setUser(result); + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await authService.login(email, password); + setUser(result); + }, []); + + const logout = useCallback(() => { + authService.logout(); + setUser(null); + }, []); + + const updateProfile = useCallback((name: string) => { + setUser(authService.updateProfile(name)); + }, []); + + const value = useMemo( + () => ({ + user, + loading, + isAuthenticated: Boolean(user), + register, + login, + logout, + updateProfile, + refresh, + }), + [user, loading, register, login, logout, updateProfile, refresh], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const value = useContext(AuthContext); + if (!value) throw new Error("useAuth must be used inside AuthProvider."); + return value; +} diff --git a/src/context/LearningContext.tsx b/src/context/LearningContext.tsx new file mode 100644 index 000000000..2d31ee1af --- /dev/null +++ b/src/context/LearningContext.tsx @@ -0,0 +1,365 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + learningService, + DEFAULT_LEARNING_STATE, +} from "@site/src/services/learningService"; + +import type { + LearningBookmark, + LearningState, +} from "@site/src/types/learning"; + +interface LearningContextValue { + learning: LearningState; + isReady: boolean; + + isBookmarked: (id: string) => boolean; + toggleBookmark: ( + bookmark: Omit, + ) => void; + removeBookmark: (id: string) => void; + + completeLesson: ( + id: string, + title: string, + courseId: string, + courseTitle: string, + xp?: number, + ) => void; + + completeChallenge: ( + id: string, + title: string, + xp?: number, + ) => void; + + setDailyGoal: (goal: number) => void; + + updateLastVisited: ( + title: string, + url: string, + courseTitle?: string, + ) => void; + + resetLearningData: () => void; +} + +const LearningContext = createContext( + undefined, +); + +function getLocalDate() { + const date = new Date(); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function getYesterday() { + const date = new Date(); + date.setDate(date.getDate() - 1); + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function calculateLevel(xp: number) { + return Math.floor(xp / 100) + 1; +} + +function createActivity( + title: string, + type: "lesson" | "challenge" | "bookmark", +) { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title, + type, + date: new Date().toISOString(), + }; +} + +export function LearningProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [learning, setLearning] = useState( + DEFAULT_LEARNING_STATE, + ); + + const [isReady, setIsReady] = useState(false); + + // Load stored learning data after the browser mounts. + useEffect(() => { + const saved = learningService.getState(); + const today = getLocalDate(); + + const normalized: LearningState = { + ...saved, + dailyCompleted: + saved.dailyGoalDate === today ? saved.dailyCompleted : 0, + dailyGoalDate: today, + }; + + setLearning(normalized); + setIsReady(true); + }, []); + + // Persist all changes after initial hydration. + useEffect(() => { + if (isReady) { + learningService.saveState(learning); + } + }, [learning, isReady]); + + const isBookmarked = useCallback( + (id: string) => learning.bookmarks.some((item) => item.id === id), + [learning.bookmarks], + ); + + const toggleBookmark = useCallback( + (bookmark: Omit) => { + setLearning((previous) => { + const exists = previous.bookmarks.some( + (item) => item.id === bookmark.id, + ); + + if (exists) { + return { + ...previous, + bookmarks: previous.bookmarks.filter( + (item) => item.id !== bookmark.id, + ), + }; + } + + return { + ...previous, + bookmarks: [ + { + ...bookmark, + addedAt: new Date().toISOString(), + }, + ...previous.bookmarks, + ], + activities: [ + createActivity(bookmark.title, "bookmark"), + ...previous.activities, + ].slice(0, 50), + }; + }); + }, + [], + ); + + const removeBookmark = useCallback((id: string) => { + setLearning((previous) => ({ + ...previous, + bookmarks: previous.bookmarks.filter( + (item) => item.id !== id, + ), + })); + }, []); + + const completeLesson = useCallback( + ( + id: string, + title: string, + courseId: string, + courseTitle: string, + xp = 20, + ) => { + setLearning((previous) => { + // Prevent duplicate XP for the same lesson. + if ( + previous.completedLessons.some((lesson) => lesson.id === id) + ) { + return previous; + } + + const today = getLocalDate(); + const yesterday = getYesterday(); + + const streak = + previous.lastActiveDate === today + ? previous.streak + : previous.lastActiveDate === yesterday + ? previous.streak + 1 + : 1; + + const dailyCompleted = + previous.dailyGoalDate === today + ? previous.dailyCompleted + 1 + : 1; + + const safeXP = Math.max(0, Math.floor(xp)); + + const completedLesson = { + id, + title, + courseId, + courseTitle, + completedAt: new Date().toISOString(), + xpEarned: safeXP, + }; + + return { + ...previous, + completedLessons: [ + completedLesson, + ...previous.completedLessons, + ], + xp: previous.xp + safeXP, + level: calculateLevel(previous.xp + safeXP), + streak, + lastActiveDate: today, + dailyCompleted, + dailyGoalDate: today, + activities: [ + createActivity(title, "lesson"), + ...previous.activities, + ].slice(0, 50), + }; + }); + }, + [], + ); + + const completeChallenge = useCallback( + (id: string, title: string, xp = 30) => { + setLearning((previous) => { + if ( + previous.completedChallenges.some( + (challenge) => challenge.id === id, + ) + ) { + return previous; + } + + const today = getLocalDate(); + const yesterday = getYesterday(); + + const streak = + previous.lastActiveDate === today + ? previous.streak + : previous.lastActiveDate === yesterday + ? previous.streak + 1 + : 1; + + const safeXP = Math.max(0, Math.floor(xp)); + + return { + ...previous, + completedChallenges: [ + { + id, + title, + completedAt: new Date().toISOString(), + xpEarned: safeXP, + }, + ...previous.completedChallenges, + ], + xp: previous.xp + safeXP, + level: calculateLevel(previous.xp + safeXP), + streak, + lastActiveDate: today, + dailyGoalDate: today, + dailyCompleted: + previous.dailyGoalDate === today + ? previous.dailyCompleted + 1 + : 1, + activities: [ + createActivity(title, "challenge"), + ...previous.activities, + ].slice(0, 50), + }; + }); + }, + [], + ); + + const setDailyGoal = useCallback((goal: number) => { + setLearning((previous) => ({ + ...previous, + dailyGoal: Math.max(1, Math.min(20, Math.floor(goal))), + })); + }, []); + + const updateLastVisited = useCallback( + (title: string, url: string, courseTitle?: string) => { + setLearning((previous) => ({ + ...previous, + lastVisited: { + title, + url, + courseTitle, + visitedAt: new Date().toISOString(), + }, + })); + }, + [], + ); + + const resetLearningData = useCallback(() => { + learningService.clearState(); + setLearning(DEFAULT_LEARNING_STATE); + }, []); + + const value = useMemo( + () => ({ + learning, + isReady, + isBookmarked, + toggleBookmark, + removeBookmark, + completeLesson, + completeChallenge, + setDailyGoal, + updateLastVisited, + resetLearningData, + }), + [ + learning, + isReady, + isBookmarked, + toggleBookmark, + removeBookmark, + completeLesson, + completeChallenge, + setDailyGoal, + updateLastVisited, + resetLearningData, + ], + ); + + return ( + + {children} + + ); +} + +export function useLearning() { + const context = useContext(LearningContext); + + if (!context) { + throw new Error("useLearning must be used inside "); + } + + return context; +} diff --git a/src/css/custom.css b/src/css/custom.css index 7820cbc9e..c797af238 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -39,12 +39,19 @@ :root { --ifm-color-primary: #2e93e2; - --ifm-color-primary-dark: #29784c; - --ifm-color-primary-darker: #277148; - --ifm-color-primary-darkest: #205d3b; - --ifm-color-primary-light: #33925d; - --ifm-color-primary-lighter: #359962; - --ifm-color-primary-lightest: #3cad6e; + /* --ifm-color-primary-dark: #29784c; */ + --ifm-color-primary-dark: #247fc7; + /* --ifm-color-primary-darker: #277148; */ + --ifm-color-primary-darker: #2074b8; + /* --ifm-color-primary-darkest: #205d3b; */ + --ifm-color-primary-darkest: #195d95; + /* --ifm-color-primary-light: #33925d; */ + --ifm-color-primary-light: #4ba2e7; + /* --ifm-color-primary-lighter: #359962; */ + --ifm-color-primary-lighter: #68b2ec; + /* --ifm-color-primary-lightest: #3cad6e; */ + --ifm-color-primary-lightest: #8bc5f1; + --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); --ifm-task-complete-color: #2e9e32e1; @@ -53,6 +60,17 @@ --image-filter: invert(0); --site-color-svg-icon-favorite: #ff1100cb; --ifm-text-color: #000; + + /* Auth page tokens */ + --chh-auth-surface: #ffffff; + --chh-auth-glass: rgba(255, 255, 255, 0.88); + --chh-auth-shadow: rgba(15, 23, 42, 0.08); + + /* Authentication UI */ + --chh-auth-card: #ffffff; + --chh-auth-border: #e5e7eb; + --chh-auth-muted: #64748b; + --chh-auth-glow: rgba(46, 147, 226, 0.12); } [data-theme="dark"] { @@ -70,8 +88,28 @@ --image-filter: invert(1); --site-color-svg-icon-favorite: #ff9100e5; --ifm-text-color: #fff; + + /* Auth page tokens */ + --chh-auth-surface: #202938; + --chh-auth-glass: rgba(26, 32, 44, 0.90); + --chh-auth-shadow: rgba(0, 0, 0, 0.28); + + /* Authentication UI */ + --chh-auth-card: #202938; + --chh-auth-border: #374151; + --chh-auth-muted: #9ca3af; + --chh-auth-glow: rgba(72, 191, 132, 0.12); +} + + +/* Keep browser controls consistent with Docusaurus theme */ +html { + color-scheme: light; } +html[data-theme="dark"] { + color-scheme: dark; +} .bg-grid-slate-200\/50 { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='rgb(226 232 240 / 0.5)'%3e%3cpath d='M0 .5H31.5V32'/%3e%3c/svg%3e"); @@ -450,4 +488,74 @@ mark { input[type="checkbox"] { display: inline; -} \ No newline at end of file +} + +/* ============ */ + + +/* CodeHarborHub navbar dropdown styling */ + +.navbar__item.dropdown > .navbar__link { + font-weight: 600; +} + +.dropdown-category { + display: block; + padding: 0.45rem 0.75rem 0.25rem; + color: var(--ifm-color-primary); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.dropdown-separator { + margin: 0.45rem 0; + border: 0; + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +.dropdown > .dropdown__menu { + min-width: 220px; + max-height: min(75vh, 620px); + overflow-y: auto; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; + padding: 0.5rem; + box-shadow: var(--ifm-global-shadow-md); +} + +.dropdown__link { + border-radius: 7px; + font-size: 0.9rem; + transition: + background-color 160ms ease, + color 160ms ease, + transform 160ms ease; +} + +.dropdown__link:hover { + background: var(--ifm-color-emphasis-100); + color: var(--ifm-color-primary); +} + +.dropdown__link--active { + background: var(--ifm-color-emphasis-100); + color: var(--ifm-color-primary); + font-weight: 700; +} + +@media (max-width: 996px) { + .navbar__items { + gap: 0.15rem; + } + + .dropdown-category { + padding-left: 1rem; + } + + .dropdown__menu { + max-height: 65vh; + overflow-y: auto; + } +} diff --git a/src/pages/auth/forgot-password.tsx b/src/pages/auth/forgot-password.tsx new file mode 100644 index 000000000..2bfbd120b --- /dev/null +++ b/src/pages/auth/forgot-password.tsx @@ -0,0 +1,59 @@ +import React, { FormEvent, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import AuthShell from "@site/src/components/auth/AuthShell"; +// @ts-ignore +import GuestRoute from "@site/src/components/auth/GuestRoute"; +// @ts-ignore +import AuthNotice from "@site/src/components/auth/AuthNotice"; +// @ts-ignore +import { authService } from "@site/src/services/authService"; + +function ForgotPasswordForm() { + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [sent, setSent] = useState(false); + + function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + try { + authService.requestPasswordReset(email); + setSent(true); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not start reset."); + } + } + + return ( + + {sent ? ( +
+ Demo account found. No email was sent and no email ownership was verified. + + Continue to demo reset + + Back to sign in +
+ ) : ( +
+ {error ? {error} : null} +
+ + setEmail(event.target.value)} placeholder="you@example.com" required /> +
+ +

This demo does not send a reset email or verify ownership of the address.

+ Back to sign in +
+ )} +
+ ); +} + +export default function ForgotPasswordPage() { + return ; +} diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx new file mode 100644 index 000000000..aa190775d --- /dev/null +++ b/src/pages/auth/login.tsx @@ -0,0 +1,463 @@ +import React, { FormEvent, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import AuthShell from "@site/src/components/auth/AuthShell"; +// @ts-ignore +import GuestRoute from "@site/src/components/auth/GuestRoute"; +// @ts-ignore +import AuthNotice from "@site/src/components/auth/AuthNotice"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +function safeNextPath(): string { + const value = new URLSearchParams(window.location.search).get("next"); + + if ( + value && + value.startsWith("/") && + !value.startsWith("//") && + !value.includes("\\") + ) { + return value; + } + + return "/dashboard"; +} + +function LoginForm() { + const { login } = useAuth(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [remember, setRemember] = useState(true); + + const resetDone = + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("reset") === "success"; + + async function submit(event: FormEvent) { + event.preventDefault(); + + setError(""); + + if (!email.trim() || !password) { + setError("Please enter your email and password."); + return; + } + + setBusy(true); + + try { + await login(email.trim(), password); + + // The current demo session is stored by AuthContext/authService. + // Remember-me is visual only in this LocalStorage prototype. + void remember; + + window.location.assign(safeNextPath()); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Something went wrong. Please try again.", + ); + } finally { + setBusy(false); + } + } + + return ( + +
+ + {/* LEFT: BRANDING / VISUAL PANEL */} + +
+ +
+ +
+ +
+ + DEVELOPER COMMUNITY +
+ +

+ Build skills. Create projects. +
+ + Shape the future. + +

+ +

+ Learn programming, explore modern technologies, practice + development, and contribute to open-source projects with + CodeHarborHub. +

+ +
+ + {/* CODE WINDOW */} + +
+ +
+ +
+ + + +
+ + + developer.js + + +
+ +
+ +

+ const developer = {"{"} +

+ +

+ learn: "Every day", +

+ +

+ build: "Real projects", +

+ +

+ contribute: "Open source", +

+ +

+ mindset: "Keep growing" +

+ +

{"};"}

+ +

+ β–‹ Start your journey... +

+ +
+ +
+ +
+ Learn Β· Build Β· Share + + CodeHarborHub + +
+ +
+ + {/* RIGHT: LOGIN FORM */} + +
+ +
+ +
+ + Member Access +
+ +

+ Sign in to your + + account. + +

+ +

+ Enter your details below to access your CodeHarborHub workspace. +

+ +
+ + {/* NOTIFICATIONS */} + + {resetDone && ( +
+ + Your demo password was updated successfully. Sign in with + your new password. + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* FORM */} + +
+ + {/* EMAIL */} + +
+ + + +
+ + + + + + + + setEmail(event.target.value)} + placeholder="you@example.com" + autoComplete="email" + maxLength={254} + required + className="w-full h-13 min-h-[52px] pl-12 pr-4 rounded-xl border border-[var(--ifm-color-emphasis-300)] bg-[var(--ifm-background-color)] text-[var(--ifm-font-color-base)] placeholder:text-[var(--ifm-color-emphasis-500)] text-sm outline-none transition-all duration-200 focus:border-[var(--ifm-color-primary)] focus:ring-4 focus:ring-[var(--ifm-color-primary)]/10 hover:border-[var(--ifm-color-primary)]/60" + /> + +
+ +
+ + {/* PASSWORD */} + +
+ +
+ + + + + Forgot password? + + +
+ +
+ + + + + + + + setPassword(event.target.value)} + placeholder="Enter your password" + autoComplete="current-password" + maxLength={128} + required + className="w-full h-13 min-h-[52px] pl-12 pr-16 rounded-xl border border-[var(--ifm-color-emphasis-300)] bg-[var(--ifm-background-color)] text-[var(--ifm-font-color-base)] placeholder:text-[var(--ifm-color-emphasis-500)] text-sm outline-none transition-all duration-200 focus:border-[var(--ifm-color-primary)] focus:ring-4 focus:ring-[var(--ifm-color-primary)]/10 hover:border-[var(--ifm-color-primary)]/60" + /> + + + +
+ +
+ + {/* REMEMBER ME */} + +
+ + + + + Local demo session + + +
+ + {/* SUBMIT */} + + + +
+ + {/* DEMO NOTICE */} + +
+ +
+ + + + + + + +
+

+ Frontend demo authentication +

+ +

+ Your account and session are stored in this browser using + LocalStorage. This is not secure production authentication. + Never enter a real or reused password. +

+
+ +
+ +
+ + {/* REGISTER */} + +
+ + Don't have an account yet?{" "} + + + Create an account + + + +
+ + {/* SOCIAL LOGIN PLACEHOLDER */} + +
+ +
+ + + CodeHarborHub Community + + +
+ +
+ +

+ Learn programming Β· Build projects Β· Contribute to open source +

+ +
+ +
+ + ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/pages/auth/register.tsx b/src/pages/auth/register.tsx new file mode 100644 index 000000000..51f4f7e47 --- /dev/null +++ b/src/pages/auth/register.tsx @@ -0,0 +1,610 @@ + +import React, { FormEvent, useMemo, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import AuthShell from "@site/src/components/auth/AuthShell"; +// @ts-ignore +import GuestRoute from "@site/src/components/auth/GuestRoute"; +// @ts-ignore +import PasswordInput from "@site/src/components/auth/PasswordInput"; +// @ts-ignore +import AuthNotice from "@site/src/components/auth/AuthNotice"; +// @ts-ignore +import { useAuth } from "@site/src/context/AuthContext"; + +function getPasswordStrength(password: string) { + const checks = [ + password.length >= 8, + /[A-Z]/.test(password), + /[a-z]/.test(password), + /\d/.test(password), + /[^A-Za-z0-9]/.test(password), + ]; + + const score = checks.filter(Boolean).length; + + let label = "Too weak"; + if (score >= 5) label = "Strong password"; + else if (score >= 3) label = "Getting stronger"; + else if (score >= 2) label = "Needs improvement"; + + return { score, checks, label }; +} + +function RegisterForm() { + const { register } = useAuth(); + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [acknowledged, setAcknowledged] = useState(false); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [focusedField, setFocusedField] = useState(""); + + const passwordStrength = useMemo( + () => getPasswordStrength(password), + [password] + ); + + const passwordsMatch = + confirm.length > 0 && password === confirm; + + const passwordMismatch = + confirm.length > 0 && password !== confirm; + + const completedFields = [ + name.trim().length >= 2, + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email), + password.length >= 8, + passwordsMatch, + acknowledged, + ].filter(Boolean).length; + + const progress = (completedFields / 5) * 100; + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + + if (name.trim().length < 2) { + setError("Please enter your full name."); + return; + } + + if (password.length < 8) { + setError("Your password must contain at least 8 characters."); + return; + } + + if (password !== confirm) { + setError("Your passwords do not match. Please check them."); + return; + } + + if (!acknowledged) { + setError("Please acknowledge the frontend-demo notice."); + return; + } + + setBusy(true); + + try { + await register( + name.trim(), + email.trim().toLowerCase(), + password + ); + + window.location.assign("/dashboard"); + } catch (reason) { + setError( + reason instanceof Error + ? reason.message + : "Could not create your account. Please try again." + ); + } finally { + setBusy(false); + } + } + + return ( + + Already have a demo account?{" "} + + Sign in + + + } + > +
+ {/* Two-column registration layout */} +
+ + {/* LEFT COLUMN β€” BRAND & COMMUNITY */} + + + {/* RIGHT COLUMN β€” REGISTRATION FORM */} +
+ + {/* Form heading */} +
+
+ + FREE DEMO ACCOUNT + + + + Step 1 of 1 + +
+ +

+ Create your account +

+ +

+ Fill in your details to create your CodeHarborHub demo profile. +

+
+ + {/* Completion progress */} +
+
+ + Profile completion + + + + {Math.round(progress)}% + +
+ +
+
+
+
+ + {/* Errors */} + {error && ( +
+ {error} +
+ )} + +
+ + {/* Full name */} +
+ + +
+ + β™™ + + + setName(event.target.value)} + onFocus={() => setFocusedField("name")} + onBlur={() => setFocusedField("")} + placeholder="Enter your full name" + autoComplete="name" + minLength={2} + maxLength={60} + required + className="w-full rounded-xl border bg-[var(--ifm-background-color)] py-3.5 pl-11 pr-4 text-sm text-[var(--ifm-font-color-base)] outline-none transition-all placeholder:text-[var(--ifm-color-emphasis-500)] focus:ring-2" + style={{ + borderColor: + focusedField === "name" + ? "var(--ifm-color-primary)" + : "var(--ifm-color-emphasis-300)", + boxShadow: + focusedField === "name" + ? "0 0 0 3px color-mix(in srgb, var(--ifm-color-primary) 15%, transparent)" + : "none", + }} + /> +
+
+ + {/* Email */} +
+ + +
+ + βœ‰ + + + setEmail(event.target.value)} + onFocus={() => setFocusedField("email")} + onBlur={() => setFocusedField("")} + placeholder="you@example.com" + autoComplete="email" + maxLength={254} + required + className="w-full rounded-xl border bg-[var(--ifm-background-color)] py-3.5 pl-11 pr-4 text-sm text-[var(--ifm-font-color-base)] outline-none transition-all placeholder:text-[var(--ifm-color-emphasis-500)] focus:ring-2" + style={{ + borderColor: + focusedField === "email" + ? "var(--ifm-color-primary)" + : "var(--ifm-color-emphasis-300)", + boxShadow: + focusedField === "email" + ? "0 0 0 3px color-mix(in srgb, var(--ifm-color-primary) 15%, transparent)" + : "none", + }} + /> +
+
+ + {/* Password */} +
+ + + {/* Password strength */} + {password.length > 0 && ( +
+
+ + Password strength + + + = 4 + ? "var(--ifm-color-primary)" + : "var(--ifm-color-emphasis-700)", + }} + > + {passwordStrength.label} + +
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+ +
+ {[ + "8+ characters", + "Uppercase letter", + "Lowercase letter", + "Number", + "Special character", + ].map((label, index) => ( +
+ + {passwordStrength.checks[index] ? "βœ“" : "β—‹"} + + {label} +
+ ))} +
+
+ )} +
+ + {/* Confirm password */} +
+ + + {passwordsMatch && ( +

+ βœ“ Your passwords match. +

+ )} + + {passwordMismatch && ( +

+ The passwords do not match. +

+ )} +
+ + {/* Demo acknowledgment */} + + + {/* Submit */} + + + {/* Footer note */} +
+ β“˜ +

+ This demo creates a local browser profile. No verification + email is sent, and your account will not sync across devices. +

+
+ +
+
+
+
+ ); +} + +export default function RegisterPage() { + return ( + + + + ); +} diff --git a/src/pages/auth/reset-password.tsx b/src/pages/auth/reset-password.tsx new file mode 100644 index 000000000..73e46bdb1 --- /dev/null +++ b/src/pages/auth/reset-password.tsx @@ -0,0 +1,60 @@ +import React, { FormEvent, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import AuthShell from "@site/src/components/auth/AuthShell"; +// @ts-ignore +import GuestRoute from "@site/src/components/auth/GuestRoute"; +// @ts-ignore +import PasswordInput from "@site/src/components/auth/PasswordInput"; +// @ts-ignore +import AuthNotice from "@site/src/components/auth/AuthNotice"; +// @ts-ignore +import { authService } from "@site/src/services/authService"; + +function ResetPasswordForm() { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + if (password !== confirm) { + setError("The passwords do not match."); + return; + } + setBusy(true); + try { + await authService.resetPassword(password); + window.location.assign("/auth/login?reset=success"); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not reset password."); + } finally { + setBusy(false); + } + } + + return ( + +
+ {error ? {error} : null} + + + +

This reset is a local simulation, not a secure email-based recovery flow.

+ Back to sign in + +
+ ); +} + +export default function ResetPasswordPage() { + return ; +} diff --git a/src/pages/bookmarks/index.tsx b/src/pages/bookmarks/index.tsx new file mode 100644 index 000000000..e1b1b406d --- /dev/null +++ b/src/pages/bookmarks/index.tsx @@ -0,0 +1,230 @@ + +import React, { useMemo, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import ProtectedRoute from "@site/src/components/auth/ProtectedRoute"; +// @ts-ignore +import DashboardLayout from "@site/src/components/auth/DashboardLayout"; +// @ts-ignore +import { useLearning } from "@site/src/context/LearningContext"; +// import type { BookmarkType } from "@site/src/types/learning"; + +const filters: Array<{ label: string; value: string }> = [ + { label: "All", value: "all" }, + { label: "Tutorials", value: "tutorial" }, + { label: "Lessons", value: "lesson" }, + { label: "Roadmaps", value: "roadmap" }, + { label: "Challenges", value: "challenge" }, + { label: "Resources", value: "resource" }, +]; + +function BookmarksContent() { + const { learning, removeBookmark, isReady } = useLearning(); + + const [search, setSearch] = useState(""); + const [activeFilter, setActiveFilter] = useState("all"); + + const bookmarks = useMemo(() => { + return learning.bookmarks.filter((bookmark: any) => { + const matchesSearch = + bookmark.title.toLowerCase().includes(search.toLowerCase()) || + bookmark.description?.toLowerCase().includes(search.toLowerCase()); + + const matchesFilter = + activeFilter === "all" || bookmark.type === activeFilter; + + return matchesSearch && matchesFilter; + }); + }, [learning.bookmarks, search, activeFilter]); + + if (!isReady) { + return ( + +

+ Loading your saved resources... +

+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+ + YOUR PERSONAL LIBRARY + + +

+ Saved for later +

+ +

+ Keep useful tutorials, roadmaps, and coding resources + together. +

+
+ +
+ πŸ”– +
+
+ +
+
+

+ Total saved +

+

+ {learning.bookmarks.length} +

+
+ +
+

+ Tutorials +

+

+ {learning.bookmarks.filter((b) => b.type === "tutorial").length} +

+
+ +
+

+ Challenges +

+

+ {learning.bookmarks.filter((b) => b.type === "challenge").length} +

+
+
+
+ + {/* Search */} +
+ setSearch(event.target.value)} + placeholder="Search your saved resources..." + aria-label="Search bookmarks" + className="w-full rounded-xl border border-[var(--ifm-color-emphasis-300)] bg-[var(--ifm-background-surface-color)] px-4 py-3 text-sm text-[var(--ifm-font-color-base)] outline-none transition focus:border-[var(--ifm-color-primary)]" + /> + +
+ {filters.map((filter) => ( + + ))} +
+
+ + {/* Bookmark cards */} + {bookmarks.length > 0 ? ( +
+ {bookmarks.map((bookmark: any) => ( +
+
+ + {bookmark.type} + + + +
+ +

+ {bookmark.title} +

+ +

+ {bookmark.description || "Saved learning resource."} +

+ +
+ + Saved {new Date(bookmark.addedAt).toLocaleDateString()} + + + + Open resource β†’ + +
+
+ ))} +
+ ) : ( +
+
πŸ”–
+ +

+ {search || activeFilter !== "all" + ? "No matching bookmarks" + : "Your library is waiting"} +

+ +

+ {search || activeFilter !== "all" + ? "Try another search or select a different category." + : "Bookmark tutorials, programming lessons, roadmaps, and challenges to find them here."} +

+ + + Explore tutorials β†’ + +
+ )} +
+
+ ); +} + +export default function BookmarksPage() { + return ( + + + + ); +} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx new file mode 100644 index 000000000..240b7b3e4 --- /dev/null +++ b/src/pages/dashboard/index.tsx @@ -0,0 +1,511 @@ + +import React, { useMemo, useState } from "react"; +// @ts-ignore +import Link from "@docusaurus/Link"; +// @ts-ignore +import ProtectedRoute from "@site/src/components/auth/ProtectedRoute"; +// @ts-ignore +import DashboardLayout from "@site/src/components/auth/DashboardLayout"; + +const learningTasks = [ + { + id: "explore", + title: "Explore the tutorial library", + description: "Find a technology you want to learn.", + href: "/tutorial/", + action: "Browse tutorials", + }, + { + id: "roadmap", + title: "Choose a learning roadmap", + description: "Build a structured learning plan.", + href: "/roadmap", + action: "View roadmaps", + }, + { + id: "project", + title: "Explore developer projects", + description: "Learn by exploring practical projects.", + href: "/projects", + action: "Explore projects", + }, +]; + +const resources = [ + { + number: "01", + icon: "β–€", + title: "Tutorial library", + description: + "Learn programming languages, frameworks, tools, and core computer science concepts.", + href: "/tutorial/", + link: "Start learning", + category: "LEARN", + }, + { + number: "02", + icon: "⌁", + title: "Learning roadmaps", + description: + "Follow structured learning paths and understand what to study next.", + href: "/roadmap", + link: "Explore paths", + category: "PLAN", + }, + { + number: "03", + icon: "β—ˆ", + title: "Developer projects", + description: + "Discover practical projects and turn your knowledge into experience.", + href: "/projects", + link: "Find projects", + category: "BUILD", + }, + { + number: "04", + icon: "⌘", + title: "Open-source community", + description: + "Explore CodeHarborHub repositories and discover ways to contribute.", + href: "https://github.com/CodeHarborHub", + link: "Visit GitHub", + category: "CONNECT", + }, +]; + +function DashboardContent() { + const [completedTasks, setCompletedTasks] = useState([]); + const [focusMode, setFocusMode] = useState(false); + + const completion = useMemo( + () => Math.round((completedTasks.length / learningTasks.length) * 100), + [completedTasks] + ); + + function toggleTask(taskId: string) { + setCompletedTasks((current) => + current.includes(taskId) + ? current.filter((id) => id !== taskId) + : [...current, taskId] + ); + } + + return ( + +
+ + {/* WELCOME HERO */} +
+ {/* Decorative shapes */} +
+ + {/* WORKSPACE STATUS */} +
+ +
+
+
+ β—‰ +
+ + GET STARTED + +
+ +

+ {completedTasks.length}/{learningTasks.length} +

+

+ Starter tasks completed +

+

+ Complete your suggested first steps. +

+
+ +
+
+
+ β—· +
+ + YOUR PACE + +
+ +

+ {completion}% +

+

+ Getting started progress +

+
+
+
+
+ +
+
+
+ β—ˆ +
+ + LOCAL DEMO + +
+ +

+ Active +

+

+ Browser session +

+

+ Your demo session is available in this browser. +

+
+
+ + {/* MAIN CONTENT GRID */} +
+ + {/* QUICK START CHECKLIST */} +
+
+
+

+ YOUR FIRST STEPS +

+

+ Quick-start checklist +

+

+ A simple guide to getting started with CodeHarborHub. +

+
+ + + {completedTasks.length} of 3 done + +
+ +
+
+
+ +
+ {learningTasks.map((task, index) => { + const done = completedTasks.includes(task.id); + + return ( +
+ + + + {task.action} β†’ + +
+ ); + })} +
+ + {completion === 100 && ( +
+

+ ✨ Your starter checklist is complete! +

+

+ You are ready to explore, learn, and build with CodeHarborHub. +

+
+ )} +
+ + {/* FOCUS PANEL */} +
+
+

+ STAY PRODUCTIVE +

+

+ Focus workspace +

+

+ Set an intention for your next learning session. +

+
+ +
+
+ {focusMode ? "✦" : "⌘"} +
+ +

+ {focusMode ? "Focus mode is on" : "Ready to focus?"} +

+ +

+ {focusMode + ? "Your intention is set. Choose a tutorial or project and get started." + : "Reduce distractions and give your next learning task your attention."} +

+ + +
+ +

+ This is a visual focus toggle, not a timed productivity tracker. +

+
+
+ + {/* RESOURCE CARDS */} +
+
+
+

+ DISCOVER CODEHARBORHUB +

+

+ Explore your workspace +

+

+ Everything you need to learn, build, and grow. +

+
+
+ +
+ {resources.map((resource) => ( +
+
+
+ {resource.icon} +
+ + + {resource.category} + +
+ +

+ {resource.title} +

+ +

+ {resource.description} +

+ + + {resource.link} + + β†’ + + +
+ ))} +
+
+ + {/* DEMO ACCOUNT NOTICE */} +
+
+
+ ⚠ +
+ +
+

+ You are using a demo account +

+

+ Your account and session are stored locally in this browser. + This prototype does not provide secure server-side + authentication, cloud synchronization, or persistent + server-backed learning progress. +

+
+
+ + + Review security β†’ + +
+ + {/* BOTTOM CTA */} +
+

+ Keep learning. Keep building. Keep sharing. +

+

+ CodeHarborHub β€” Level Up Skills with CodeHarborHub. +

+
+
+
+ ); +} + +export default function DashboardPage() { + return ( + + + + ); +} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx new file mode 100644 index 000000000..75d56386b --- /dev/null +++ b/src/pages/profile/index.tsx @@ -0,0 +1,503 @@ + +import React, { + FormEvent, + useEffect, + useMemo, + useState, +} from "react"; +import Link from "@docusaurus/Link"; + +import ProtectedRoute from "@site/src/components/auth/ProtectedRoute"; +import DashboardLayout from "@site/src/components/auth/DashboardLayout"; +import AuthNotice from "@site/src/components/auth/AuthNotice"; +import { useAuth } from "@site/src/context/AuthContext"; + +type NoticeState = { + type: "success" | "error"; + message: string; +} | null; + +function getInitials(name: string) { + const parts = name.trim().split(/\s+/).filter(Boolean); + + if (!parts.length) return "CH"; + + return parts + .slice(0, 2) + .map((part) => part.charAt(0).toUpperCase()) + .join(""); +} + +function ProfileContent() { + const { user, updateProfile } = useAuth(); + + const [name, setName] = useState(user?.name ?? ""); + const [notice, setNotice] = useState(null); + const [busy, setBusy] = useState(false); + + // Keep the editable value synchronized if the user context changes. + useEffect(() => { + setName(user?.name ?? ""); + }, [user?.name]); + + const displayName = user?.name?.trim() || "CodeHarborHub Member"; + const email = user?.email ?? ""; + + const previewName = name.trim() || "Your display name"; + const initials = getInitials(name || displayName); + + const completion = useMemo(() => { + const checks = [ + Boolean(name.trim().length >= 2), + Boolean(email), + ]; + + return Math.round( + (checks.filter(Boolean).length / checks.length) * 100 + ); + }, [name, email]); + + const hasChanges = name.trim() !== displayName && + name.trim().length >= 2; + + function submit(event: FormEvent) { + event.preventDefault(); + setNotice(null); + + const cleanName = name.trim(); + + if (cleanName.length < 2) { + setNotice({ + type: "error", + message: "Your display name must contain at least 2 characters.", + }); + return; + } + + if (cleanName.length > 60) { + setNotice({ + type: "error", + message: "Your display name cannot exceed 60 characters.", + }); + return; + } + + setBusy(true); + + try { + updateProfile(cleanName); + + setName(cleanName); + setNotice({ + type: "success", + message: "Your demo profile was successfully updated.", + }); + } catch (reason) { + setNotice({ + type: "error", + message: + reason instanceof Error + ? reason.message + : "Could not update your profile. Please try again.", + }); + } finally { + setBusy(false); + } + } + + function resetName() { + setName(user?.name ?? ""); + setNotice(null); + } + + return ( + +
+ + {/* PROFILE HERO */} +
+
+ + {/* PROFILE MAIN GRID */} +
+ + {/* EDIT PROFILE */} +
+ +
+
+

+ PERSONAL INFORMATION +

+ +

+ Edit your profile +

+ +

+ Update how your identity appears in your workspace. +

+
+ +
+ ✎ +
+
+ + {notice && ( +
+ + {notice.message} + +
+ )} + +
+ + {/* Display name */} +
+ + + { + setName(event.target.value); + setNotice(null); + }} + placeholder="Enter your full name" + autoComplete="name" + minLength={2} + maxLength={60} + required + className="w-full rounded-xl border border-[var(--ifm-color-emphasis-300)] bg-[var(--ifm-background-color)] px-4 py-3.5 text-sm text-[var(--ifm-font-color-base)] outline-none transition-all placeholder:text-[var(--ifm-color-emphasis-500)] focus:border-[var(--ifm-color-primary)] focus:ring-2 focus:ring-[var(--ifm-color-primary)]/20" + /> + +
+ + This name appears in your workspace. + + + + {name.length}/60 + +
+
+ + {/* Email address */} +
+ + +
+ + + + Read only + +
+ +

+ Email editing is not available in this frontend demo. +

+
+ + {/* Save controls */} +
+ + + +
+
+
+ + {/* LIVE PROFILE PREVIEW */} + +
+ + {/* DEMO INFORMATION */} +
+ β“˜ + +
+

+ About your demo profile +

+ +

+ Profile changes are saved through your local demo authentication + provider. This profile does not currently support profile photos, + public profile publishing, email changes, or cloud synchronization. + Do not store sensitive personal information in this prototype. +

+
+
+
+
+ ); +} + +export default function ProfilePage() { + return ( + + + + ); +} diff --git a/src/pages/settings/account.tsx b/src/pages/settings/account.tsx new file mode 100644 index 000000000..8cfb1d1ce --- /dev/null +++ b/src/pages/settings/account.tsx @@ -0,0 +1,338 @@ + +import React, { useMemo } from "react"; +import Link from "@docusaurus/Link"; +import ProtectedRoute from "@site/src/components/auth/ProtectedRoute"; +import DashboardLayout from "@site/src/components/auth/DashboardLayout"; +import { useAuth } from "@site/src/context/AuthContext"; + +function formatDate(value?: string) { + if (!value) return "Not available"; + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) return "Not available"; + + return date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function AccountContent() { + const { user } = useAuth(); + + const accountDetails = useMemo( + () => [ + { + label: "Display name", + value: user?.name || "Not provided", + icon: "πŸ‘€", + }, + { + label: "Email address", + value: user?.email || "Not provided", + icon: "βœ‰οΈ", + }, + { + label: "Account created", + value: formatDate(user?.createdAt), + icon: "πŸ“…", + }, + { + label: "Last profile update", + value: formatDate(user?.updatedAt), + icon: "πŸ”„", + }, + ], + [user], + ); + + const accountInitials = (user?.name || "CH") + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join(""); + + return ( + +
+ + {/* Account overview */} +
+
+ +
+
+
+ {accountInitials || "CH"} +
+ +
+ + ACCOUNT OVERVIEW + + +

+ {user?.name || "CodeHarborHub Member"} +

+ +

+ {user?.email || "No email available"} +

+ + + + Browser-local demo account + +
+
+ + + Edit profile + + +
+
+ + {/* Account information */} +
+
+
+ + ACCOUNT INFORMATION + + +

+ Your account details +

+ +

+ Review the information associated with your demo account. +

+
+ + + Read only + +
+ +
+ {accountDetails.map((item) => ( +
+
+ + {item.icon} + + + + {item.label} + +
+ +

+ {item.value} +

+
+ ))} +
+ +
+

+ Account information is read from your local demo session. +

+ + + Update personal details β†’ + +
+
+ + {/* Privacy and storage */} +
+
+
+ πŸ” +
+ +
+ + DATA & PRIVACY + + +

+ Where is your data stored? +

+ +

+ Understand how this frontend demonstration handles your + account information. +

+
+
+ +
+
+
+ πŸ’» +

+ Browser storage +

+
+ +

+ Demo account information is stored in LocalStorage on this + browser and website origin. +

+
+ +
+
+ ☁️ +

+ Cloud synchronization +

+
+ +

+ This demo does not upload your account record to CodeHarborHub + servers or synchronize it across devices. +

+
+
+ +
+
+ ⚠️ + +
+

+ Important security information +

+ +

+ LocalStorage can be inspected or modified by anyone with + access to this browser. This frontend demo is not production + authentication. Do not store real passwords, payment + information, or sensitive personal data here. +

+
+
+
+
+ + {/* Settings navigation */} +
+ + QUICK NAVIGATION + + +

+ Manage your account +

+ +
+ +
+ + Personal profile + + + Update your display name + +
+ + β†’ + + + + +
+ + Security settings + + + Review demo security options + +
+ + β†’ + + + + +
+ + Dashboard + + + Return to your workspace + +
+ + β†’ + + +
+
+ + {/* Footer note */} +
+
+

+ CodeHarborHub Account Center +

+

+ Manage your learning identity and account preferences. +

+
+ + + Explore tutorials β†’ + +
+
+
+ ); +} + +export default function AccountSettingsPage() { + return ( + + + + ); +} diff --git a/src/pages/settings/security.tsx b/src/pages/settings/security.tsx new file mode 100644 index 000000000..54ad6eac5 --- /dev/null +++ b/src/pages/settings/security.tsx @@ -0,0 +1,375 @@ + +import React, { useState } from "react"; +import Link from "@docusaurus/Link"; +import ProtectedRoute from "@site/src/components/auth/ProtectedRoute"; +import DashboardLayout from "@site/src/components/auth/DashboardLayout"; +import AuthNotice from "@site/src/components/auth/AuthNotice"; +import { authService } from "@site/src/services/authService"; +import { useAuth } from "@site/src/context/AuthContext"; + +function SecurityContent() { + const { logout } = useAuth(); + + const [showConfirm, setShowConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [notice, setNotice] = useState(""); + + function clearData() { + try { + setIsDeleting(true); + setNotice(""); + + // Remove browser-local demo authentication data. + authService.clearDemoData(); + + setShowConfirm(false); + setNotice("Demo account data has been deleted."); + + // Clear the current authentication context. + logout(); + + // Redirect to registration after logout. + window.location.assign("/auth/register"); + } catch (error) { + setIsDeleting(false); + setNotice( + error instanceof Error + ? error.message + : "Unable to delete demo data. Please try again.", + ); + } + } + + return ( + +
+ + {/* Security overview */} +
+
+ +
+
+
+
+ πŸ” +
+ +
+ + SECURITY CENTER + + +

+ Your account security +

+ +

+ Understand how this demo handles your session and data. +

+
+
+ + + + Demo environment + +
+ +
+
+ πŸ’» +

+ Local storage +

+

+ Data stays in this browser's website storage. +

+
+ +
+ ⏱️ +

+ Session duration +

+

+ Demo session is designed to last up to seven days. +

+
+ +
+ ☁️ +

+ Server verification +

+

+ No server-side authentication is implemented. +

+
+
+
+
+ + {/* Session information */} +
+
+
+ πŸ–₯️ +
+ +
+ + SESSION MANAGEMENT + + +

+ Browser-local session +

+ +

+ Your demo session is maintained using LocalStorage. +

+
+
+ +
+
+ βœ“ +
+

+ Local session record +

+

+ The application stores a session record in your browser. +

+
+
+ +
+ ⏳ +
+

+ Seven-day session design +

+

+ The demo is designed to expire the session after up to + seven days, depending on the authentication service logic. +

+
+
+ +
+ ⚠️ +
+

+ Not server verified +

+

+ This session is not validated by a secure authentication + server and must not be used for production authorization. +

+
+
+
+
+ + {/* Password recovery */} +
+
+
+ πŸ”‘ +
+ +
+ + PASSWORD & RECOVERY + + +

+ Demo password recovery +

+ +

+ Password recovery is simulated in the browser. The demo does + not send an email, issue a real verification link, or perform + an identity check. +

+ + + View recovery demo + β†’ + +
+
+
+ + {/* Privacy information */} +
+ + PRIVACY & DATA + + +

+ Your data, your browser +

+ +

+ This frontend demonstration uses browser storage rather than a + centralized user database. +

+ +
+
+ πŸ“¦ +

+ What is stored? +

+

+ Demo user records, session information, and pending password + reset state managed by the authentication feature. +

+
+ +
+ 🧹 +

+ How can you remove it? +

+

+ Use the delete demo data option below to clear data managed + by this authentication feature in this browser. +

+
+
+
+ + {/* Danger zone */} +
+
+
+ + ⚠️ + + +
+ + DANGER ZONE + + +

+ Delete demo account data +

+
+
+
+ +
+

+ This action removes demo users, session information, and + pending password reset state managed by this feature in the + current browser. It does not delete unrelated browser data or + data stored by other applications. +

+ + {!showConfirm ? ( + + ) : ( +
+
+

+ Are you absolutely sure? +

+ +

+ You will be signed out and redirected to the registration + page. This action cannot be undone. +

+
+ +
+ + + +
+
+ )} + + {notice && ( +
+ {notice} +
+ )} +
+
+ + {/* Helpful links */} +
+
+

+ Need help managing your account? +

+

+ Review your account information and profile preferences. +

+
+ +
+ + Account settings β†’ + + + + My profile β†’ + +
+
+
+
+ ); +} + +export default function SecuritySettingsPage() { + return ( + + + + ); +} diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 000000000..26f0e0b99 --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,265 @@ +export interface DemoUser { + id: string; + name: string; + email: string; + passwordHash: string; + salt: string; + createdAt: string; + updatedAt: string; +} + +export interface PublicUser { + id: string; + name: string; + email: string; + createdAt: string; + updatedAt: string; +} + +interface DemoSession { + userId: string; + sessionId: string; + createdAt: number; + expiresAt: number; +} + +const USERS_KEY = "chh_demo_v1_users"; +const SESSION_KEY = "chh_demo_v1_session"; +const RESET_KEY = "chh_demo_v1_reset"; +const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; +const PBKDF2_ITERATIONS = 150_000; + +function storage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function readUsers(): DemoUser[] { + try { + const raw = storage()?.getItem(USERS_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as DemoUser[]) : []; + } catch { + return []; + } +} + +function writeUsers(users: DemoUser[]): void { + const target = storage(); + if (!target) throw new Error("Browser storage is unavailable."); + try { + target.setItem(USERS_KEY, JSON.stringify(users)); + } catch { + throw new Error("Could not save demo account. Check browser storage settings."); + } +} + +function publicUser(user: DemoUser): PublicUser { + const { id, name, email, createdAt, updatedAt } = user; + return { id, name, email, createdAt, updatedAt }; +} + +function randomHex(byteLength = 16): string { + if (!globalThis.crypto?.getRandomValues) { + throw new Error("Secure random generation is unavailable. Use HTTPS or localhost."); + } + const bytes = new Uint8Array(byteLength); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function createId(): string { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + return randomHex(16); +} + +async function derivePassword(password: string, salt: string): Promise { + if (!globalThis.crypto?.subtle) { + throw new Error("Web Crypto is unavailable. Use HTTPS or localhost."); + } + const key = await globalThis.crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits"], + ); + const bits = await globalThis.crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: new TextEncoder().encode(salt), + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256", + }, + key, + 256, + ); + return Array.from(new Uint8Array(bits), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function saveSession(userId: string): void { + const target = storage(); + if (!target) throw new Error("Browser storage is unavailable."); + const now = Date.now(); + const session: DemoSession = { + userId, + sessionId: createId(), + createdAt: now, + expiresAt: now + SESSION_DURATION, + }; + target.setItem(SESSION_KEY, JSON.stringify(session)); +} + +function readSession(): DemoSession | null { + try { + const raw = storage()?.getItem(SESSION_KEY); + if (!raw) return null; + const session = JSON.parse(raw) as DemoSession; + if (!session?.userId || !session?.sessionId || session.expiresAt <= Date.now()) { + storage()?.removeItem(SESSION_KEY); + return null; + } + return session; + } catch { + storage()?.removeItem(SESSION_KEY); + return null; + } +} + +function validateName(name: string): string { + const clean = name.trim().replace(/\s+/g, " "); + if (clean.length < 2 || clean.length > 60) { + throw new Error("Name must be between 2 and 60 characters."); + } + return clean; +} + +function validateEmail(email: string): string { + const clean = email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean) || clean.length > 254) { + throw new Error("Enter a valid email address."); + } + return clean; +} + +function validatePassword(password: string): void { + if (password.length < 8 || password.length > 128) { + throw new Error("Password must be between 8 and 128 characters."); + } +} + +export const authService = { + async register(name: string, email: string, password: string): Promise { + const cleanName = validateName(name); + const cleanEmail = validateEmail(email); + validatePassword(password); + + const users = readUsers(); + if (users.some((user) => user.email === cleanEmail)) { + throw new Error("An account with this email already exists in this browser."); + } + + const salt = randomHex(16); + const passwordHash = await derivePassword(password, salt); + const now = new Date().toISOString(); + + const user: DemoUser = { + id: createId(), + name: cleanName, + email: cleanEmail, + passwordHash, + salt, + createdAt: now, + updatedAt: now, + }; + + users.push(user); + writeUsers(users); + saveSession(user.id); + return publicUser(user); + }, + + async login(email: string, password: string): Promise { + const cleanEmail = validateEmail(email); + const user = readUsers().find((item) => item.email === cleanEmail); + if (!user) throw new Error("Email or password is incorrect."); + + const attempted = await derivePassword(password, user.salt); + if (attempted !== user.passwordHash) { + throw new Error("Email or password is incorrect."); + } + + saveSession(user.id); + return publicUser(user); + }, + + getCurrentUser(): PublicUser | null { + const session = readSession(); + if (!session) return null; + const user = readUsers().find((item) => item.id === session.userId); + return user ? publicUser(user) : null; + }, + + logout(): void { + storage()?.removeItem(SESSION_KEY); + }, + + updateProfile(name: string): PublicUser { + const current = this.getCurrentUser(); + if (!current) throw new Error("Your demo session has expired. Sign in again."); + + const users = readUsers(); + const index = users.findIndex((item) => item.id === current.id); + if (index < 0) throw new Error("Demo account could not be found."); + + users[index].name = validateName(name); + users[index].updatedAt = new Date().toISOString(); + writeUsers(users); + return publicUser(users[index]); + }, + + requestPasswordReset(email: string): void { + const cleanEmail = validateEmail(email); + const exists = readUsers().some((item) => item.email === cleanEmail); + if (!exists) { + throw new Error("No demo account with that email exists in this browser."); + } + // This is intentionally not a secure token and does not prove email ownership. + storage()?.setItem(RESET_KEY, cleanEmail); + }, + + async resetPassword(password: string): Promise { + validatePassword(password); + const target = storage(); + const email = target?.getItem(RESET_KEY); + if (!email) throw new Error("Reset session not found. Start again."); + + const users = readUsers(); + const index = users.findIndex((item) => item.email === email); + if (index < 0) throw new Error("Demo account could not be found."); + + const salt = randomHex(16); + users[index].salt = salt; + users[index].passwordHash = await derivePassword(password, salt); + users[index].updatedAt = new Date().toISOString(); + + writeUsers(users); + target?.removeItem(RESET_KEY); + target?.removeItem(SESSION_KEY); + }, + + clearDemoData(): void { + storage()?.removeItem(USERS_KEY); + storage()?.removeItem(SESSION_KEY); + storage()?.removeItem(RESET_KEY); + }, + + getSessionExpiry(): number | null { + return readSession()?.expiresAt ?? null; + }, +}; diff --git a/src/services/learningService.ts b/src/services/learningService.ts new file mode 100644 index 000000000..d32b0a051 --- /dev/null +++ b/src/services/learningService.ts @@ -0,0 +1,71 @@ +// @ts-ignore +import type { LearningState } from "@site/src/types/learning"; + +const STORAGE_KEY = "chh_learning_v1"; + +export const DEFAULT_LEARNING_STATE: LearningState = { + bookmarks: [], + completedLessons: [], + completedChallenges: [], + activities: [], + xp: 0, + level: 1, + streak: 0, + lastActiveDate: null, + dailyGoal: 3, + dailyCompleted: 0, + dailyGoalDate: new Date().toLocaleDateString("en-CA"), + lastVisited: null, +}; + +function isBrowser() { + return typeof window !== "undefined"; +} + +export const learningService = { + getState(): LearningState { + if (!isBrowser()) return DEFAULT_LEARNING_STATE; + + try { + const raw = localStorage.getItem(STORAGE_KEY); + + if (!raw) return DEFAULT_LEARNING_STATE; + + const parsed = JSON.parse(raw) as Partial; + + return { + ...DEFAULT_LEARNING_STATE, + ...parsed, + bookmarks: Array.isArray(parsed.bookmarks) + ? parsed.bookmarks + : [], + completedLessons: Array.isArray(parsed.completedLessons) + ? parsed.completedLessons + : [], + completedChallenges: Array.isArray(parsed.completedChallenges) + ? parsed.completedChallenges + : [], + activities: Array.isArray(parsed.activities) + ? parsed.activities + : [], + }; + } catch { + return DEFAULT_LEARNING_STATE; + } + }, + + saveState(state: LearningState) { + if (!isBrowser()) return; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("Could not save learning data:", error); + } + }, + + clearState() { + if (!isBrowser()) return; + localStorage.removeItem(STORAGE_KEY); + }, +}; diff --git a/src/theme/MDXComponents.js b/src/theme/MDXComponents.js index 7102ca371..f557effc0 100644 --- a/src/theme/MDXComponents.js +++ b/src/theme/MDXComponents.js @@ -19,6 +19,7 @@ import Admonition from '@theme/Admonition'; import TOCInline from '@theme/TOCInline'; import CodePenEmbed from '@site/src/components/CodePenEmbed'; import CodePreview from '@site/src/components/CodePreview'; +import BookmarkButton from "@site/src/components/learning/BookmarkButton"; export default { // Re-use the default mapping @@ -42,4 +43,15 @@ export default { TOCInline, CodePenEmbed, CodePreview, + BookmarkButton, + // }; diff --git a/src/theme/NavbarItem/ComponentTypes.tsx b/src/theme/NavbarItem/ComponentTypes.tsx new file mode 100644 index 000000000..e329723fd --- /dev/null +++ b/src/theme/NavbarItem/ComponentTypes.tsx @@ -0,0 +1,13 @@ + +import React from "react"; +import OriginalComponentTypes from "@theme-original/NavbarItem/ComponentTypes"; +import AuthNavbarItem from "@site/src/components/AuthNavbarItem"; + +const ComponentTypes = { + ...OriginalComponentTypes, + + // Custom CodeHarborHub authentication navbar item + "custom-authButton": AuthNavbarItem, +}; + +export default ComponentTypes; diff --git a/src/theme/Root.js b/src/theme/Root.js index 45c6f0b9f..d0bdeb20c 100644 --- a/src/theme/Root.js +++ b/src/theme/Root.js @@ -1,11 +1,16 @@ -import React from 'react'; -import AntiAdBlock from '@site/src/components/AntiAdBlock'; -export default function Root({children}) { +import React from "react"; +import { AuthProvider } from "@site/src/context/AuthContext"; +import { LearningProvider } from "@site/src/context/LearningContext"; + +export default function Root({ + children +}) { return ( - <> - - {children} - + + + {children} + + ); -} \ No newline at end of file +} diff --git a/src/types/learning.ts b/src/types/learning.ts new file mode 100644 index 000000000..51a1a0902 --- /dev/null +++ b/src/types/learning.ts @@ -0,0 +1,59 @@ + +export type BookmarkType = + | "tutorial" + | "lesson" + | "roadmap" + | "challenge" + | "resource"; + +export interface LearningBookmark { + id: string; + title: string; + url: string; + type: BookmarkType; + description?: string; + addedAt: string; +} + +export interface CompletedLesson { + id: string; + title: string; + courseId: string; + courseTitle: string; + completedAt: string; + xpEarned: number; +} + +export interface CompletedChallenge { + id: string; + title: string; + completedAt: string; + xpEarned: number; +} + +export interface LearningActivity { + id: string; + title: string; + type: "lesson" | "challenge" | "bookmark"; + date: string; +} + +export interface LearningState { + bookmarks: LearningBookmark[]; + completedLessons: CompletedLesson[]; + completedChallenges: CompletedChallenge[]; + activities: LearningActivity[]; + xp: number; + level: number; + streak: number; + lastActiveDate: string | null; + dailyGoal: number; + dailyCompleted: number; + dailyGoalDate: string; + lastVisited: { + title: string; + url: string; + courseTitle?: string; + visitedAt: string; + } | null; +} diff --git a/src/utils/learningHelpers.ts b/src/utils/learningHelpers.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/utils/learningStorage.ts b/src/utils/learningStorage.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 000000000..e69de29bb diff --git a/tailwind.config.js b/tailwind.config.js index 07dadf55d..3c93727ca 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,15 +6,36 @@ module.exports = { preflight: false, container: false, }, + darkMode: ["class", '[data-theme="dark"]'], - content: ["./src/**/*.{jsx,tsx,html}"], + + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + "./docs/**/*.{md,mdx}", + "./blog/**/*.{md,mdx}", + ], + theme: { extend: { + colors: { + brand: { + DEFAULT: "var(--ifm-color-primary)", + dark: "var(--ifm-color-primary-dark)", + light: "var(--ifm-color-primary-light)", + }, + }, + fontFamily: { - sans: ['"Inter"', ...fontFamily.sans], + sans: ["Inter", "system-ui", "sans-serif"], + // sans: ['"Inter"', ...fontFamily.sans], jakarta: ['"Plus Jakarta Sans"', ...fontFamily.sans], mono: ['"Fira Code"', ...fontFamily.mono], }, + + boxShadow: { + brand: "0 10px 30px color-mix(in srgb, var(--ifm-color-primary) 20%, transparent)", + }, + borderRadius: { sm: "4px", }, diff --git a/tutorials/html/15-performance/html-performance-basics.mdx b/tutorials/html/15-performance/html-performance-basics.mdx new file mode 100644 index 000000000..e69de29bb diff --git a/tutorials/html/15-performance/index.mdx b/tutorials/html/15-performance/index.mdx index e69de29bb..124dc3dac 100644 --- a/tutorials/html/15-performance/index.mdx +++ b/tutorials/html/15-performance/index.mdx @@ -0,0 +1,608 @@ +--- +id: performance-overview +title: "HTML Performance: Making Pages Load Fast" +sidebar_label: "Performance" +sidebar_position: 1 +slug: /html/performance +description: "Start learning web performance with HTML: how a browser loads a page, what blocks it, which HTML attributes and tags speed things up (preload, preconnect, defer, async, lazy loading, responsive images), the Core Web Vitals, and a map of every lesson in this section. Includes a loading timeline simulator, a Core Web Vitals quiz, and a which-tool-fits matcher." +keywords: + - HTML performance + - web performance for beginners + - page speed + - Core Web Vitals + - render blocking + - preload preconnect + - defer async + - lazy loading + - learn HTML + - CodeHarborHub HTML +tags: [html, web-development, beginner, performance, page-speed, core-web-vitals, resource-hints, frontend] +--- + +import {useState} from 'react'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import AdsComponent from '@site/src/components/AdsComponent'; +import BrowserWindow from '@site/src/components/BrowserWindow'; + +export function simulateLoad(options) { + const ttfb = 200; + const htmlDone = 300; + const cssStart = 220; + const cssDone = cssStart + 300; + const scriptStart = 230; + const scriptDownloaded = scriptStart + 400; + const scriptReady = scriptDownloaded + 150; + let fcp = Math.max(htmlDone, cssDone); + if (options.script === "blocking") fcp = Math.max(fcp, scriptReady); + let heroStart; + if (options.preloadHero) heroStart = ttfb + 5; + else if (options.hero === "html") heroStart = 240; + else if (options.hero === "css") heroStart = cssDone; + else heroStart = cssDone + 100; + const heroDone = heroStart + 700; + const lcp = Math.max(fcp, heroDone); + const connectDone = options.preconnect ? ttfb + 5 + 400 : null; + const tagStart = options.preconnect ? Math.max(scriptReady, connectDone) : scriptReady + 400; + const tagDone = tagStart + 300; + const bars = [ + {id: "html", label: "HTML document", start: 0, end: htmlDone}, + {id: "css", label: "styles.css (in the head)", start: cssStart, end: cssDone}, + {id: "js", label: "app.js (" + options.script + ")", start: scriptStart, end: scriptDownloaded}, + {id: "jsrun", label: "app.js runs", start: scriptDownloaded, end: scriptReady}, + {id: "hero", label: "Hero image" + (options.preloadHero ? " (preloaded)" : ""), start: heroStart, end: heroDone}, + ]; + if (options.preconnect) bars.push({id: "conn", label: "Connection to the tag server", start: ttfb + 5, end: connectDone}); + bars.push({id: "tag", label: "Third-party tag" + (options.preconnect ? "" : " (connects first)"), start: tagStart, end: tagDone}); + return {bars, fcp, lcp, scriptReady, tagDone}; +} + +export function LoadTimelineLab() { + const [script, setScript] = useState("blocking"); + const [hero, setHero] = useState("css"); + const [preloadHero, setPreloadHero] = useState(false); + const [preconnect, setPreconnect] = useState(false); + const result = simulateLoad({script, hero, preloadHero, preconnect}); + const scale = 1600; + return ( +
+
+ How is app.js loaded? + + + +
+
+ How is the hero image added? + + + +
+ + +
    + {result.bars.map((bar) => ( +
  • +
    {bar.label}: {bar.start} to {bar.end} ms
    +
  • + ))} +
+
    +
  • First content shown (FCP): {result.fcp} ms
  • +
  • Hero image shown (LCP): {result.lcp} ms
  • +
  • app.js ready: {result.scriptReady} ms
  • +
  • Third-party tag loaded: {result.tagDone} ms
  • +
+

+ This is a simplified simulation with made-up numbers, so you can see the relative effect of each choice. Real browsers are smarter and messier: they share bandwidth between files, scan ahead for resources, cache files, and give different files different priorities. Never trust a simulation for your own site. Measure it, with the tools described below. +

+
+ ); +} + +export const vitalsItems = [ + {id: "v1", text: "LCP of 1.8 seconds", expect: "good"}, + {id: "v2", text: "LCP of 3.4 seconds", expect: "needs"}, + {id: "v3", text: "LCP of 5.2 seconds", expect: "poor"}, + {id: "v4", text: "INP of 150 milliseconds", expect: "good"}, + {id: "v5", text: "INP of 350 milliseconds", expect: "needs"}, + {id: "v6", text: "INP of 620 milliseconds", expect: "poor"}, + {id: "v7", text: "CLS of 0.05", expect: "good"}, + {id: "v8", text: "CLS of 0.18", expect: "needs"}, + {id: "v9", text: "CLS of 0.4", expect: "poor"}, +]; + +export function VitalsQuiz() { + const [picks, setPicks] = useState({}); + const [shown, setShown] = useState(false); + const labels = {good: "Good", needs: "Needs improvement", poor: "Poor"}; + const right = vitalsItems.filter((item) => picks[item.id] === item.expect).length; + return ( +
+
    +
  • LCP (Largest Contentful Paint): how long until the biggest thing in view has appeared. Loading speed.
  • +
  • INP (Interaction to Next Paint): how quickly the page responds after a tap, click, or key press. Responsiveness.
  • +
  • CLS (Cumulative Layout Shift): how much the page jumps around while loading. Visual stability.
  • +
+

Rate each result. Use the published thresholds: LCP good up to 2.5 s and poor over 4 s. INP good up to 200 ms and poor over 500 ms. CLS good up to 0.1 and poor over 0.25.

+ {vitalsItems.map((item) => ( +
+ {item.text} + {Object.keys(labels).map((key) => ( + + ))} + {shown && picks[item.id] ? {picks[item.id] === item.expect ? "Correct." : "It is " + labels[item.expect].toLowerCase() + "."} : null} +
+ ))} +

+ {" "} + +

+

{shown ? right + " of " + vitalsItems.length + " correct." : ""}

+

+ These are the thresholds Google publishes at the time of writing. The set of metrics and the numbers can change, so check the current web.dev documentation. The full lesson is Core Web Vitals, in this section. +

+
+ ); +} + +export const toolScenarios = [ + {id: "t1", text: "The hero image is a CSS background, so the browser finds it late", expect: "preload"}, + {id: "t2", text: "You know the page will call another site's API very soon", expect: "preconnect"}, + {id: "t3", text: "Your page touches a dozen third-party hosts, and you want a cheap head start on each name lookup", expect: "dns-prefetch"}, + {id: "t4", text: "Most visitors click Next, and you want the next page ready", expect: "prefetch"}, + {id: "t5", text: "Your main script needs the finished page, and must run in order", expect: "defer"}, + {id: "t6", text: "An analytics script that depends on nothing else", expect: "async"}, + {id: "t7", text: "A gallery of 40 images, far below the first screen", expect: "lazy"}, + {id: "t8", text: "Phones are downloading a 3000 pixel photo to show it 360 pixels wide", expect: "responsive"}, +]; + +export function ToolMatcher() { + const [picks, setPicks] = useState({}); + const [shown, setShown] = useState(false); + const options = [ + {value: "preload", label: "preload"}, + {value: "preconnect", label: "preconnect"}, + {value: "dns-prefetch", label: "dns-prefetch"}, + {value: "prefetch", label: "prefetch"}, + {value: "defer", label: "defer"}, + {value: "async", label: "async"}, + {value: "lazy", label: "loading=lazy"}, + {value: "responsive", label: "srcset and sizes"}, + ]; + const labelOf = (value) => (options.find((option) => option.value === value) || {label: value}).label; + const right = toolScenarios.filter((item) => picks[item.id] === item.expect).length; + return ( +
+

Choose the best tool for each problem.

+ {toolScenarios.map((item) => ( +

+ {" "} + + {shown && picks[item.id] ? {picks[item.id] === item.expect ? "Good." : "Better: " + labelOf(item.expect) + "."} : null} +

+ ))} +

+ {" "} + +

+

{shown ? right + " of " + toolScenarios.length + " match the usual choice." : ""}

+
+ ); +} + +A page can be beautiful, accurate, and well built, and still lose its visitors in the first few seconds. People do not wait. On a slow phone in a moving train, every extra second is a chance for someone to give up, and every kilobyte costs real money for someone on a limited data plan. + +**Performance** is how quickly and how smoothly your page loads and responds. A surprising amount of it is decided by **plain HTML:** which files you ask for, in what order, with which attributes. This section teaches you those choices, one at a time, and how to **measure** whether they worked. + + +
+ +## What You Will Learn in This Section + +- How a browser **loads and shows** a page +- What **blocks** the page, and what does not +- How to use **resource hints:** preload, prefetch, preconnect, and dns-prefetch +- How to load **scripts** without freezing the page: `defer` and `async` +- How to load **images** at the right size and at the right time +- What the **Core Web Vitals** measure +- How to **measure** and keep your gains + +## The Big Idea + + + + +```html title="index.html" + + + + The Bread Bin + + + + + + + + + + + + + + A golden loaf on a wooden board + + + Rye loaves on a rack + +``` + + + + + +<> + + + + + + + +Try the options one at a time. Watch what a blocking script does to the first paint, and what a preload does to a late-found image. + +## Why Performance Matters + +- **People leave slow pages.** Studies by large sites have repeatedly linked faster pages to more completed visits and purchases. The exact numbers vary, so measure your own site. +- **Not everyone has a fast phone or network.** A page that is fine on your laptop may be painful on a mid-range phone on a weak signal. +- **Data costs money.** Heavy pages are expensive for people on limited plans. +- **Speed is part of accessibility.** Long freezes and jumping layouts are hardest on people who use assistive technology, have motor or cognitive difficulties, or need more time. +- **Search engines notice.** Page experience is one of many signals, and a small one next to content quality. See [HTML SEO Basics](/tutorials/html/seo/html-seo-basics). +- **It saves energy.** Less data and less work means less power used, on both the server and the device. + +## How a Browser Loads a Page + +In very simple terms: + +1. **Request.** The browser asks the server for the HTML, and waits for the first byte (the **time to first byte**). +2. **Parse.** It reads the HTML from top to bottom, and **discovers** other files to fetch: stylesheets, scripts, images, fonts. +3. **Fetch.** It downloads those files, often several at once, on connections it may need to open first (a DNS lookup, a TCP connection, and encryption). +4. **Build.** It combines the HTML and the CSS, works out the layout, and **paints** the page. +5. **Run.** It runs JavaScript, which can change the page, and respond to the visitor. + +At each step, something can **wait on something else.** Performance work is mostly about **removing the waiting.** + +### What blocks the page + +| Thing | What it blocks | Typical fix | +| :-- | :-- | :-- | +| **A stylesheet in the head** | Painting the page (the browser will not draw without the styles) | Keep CSS small, and load only what is needed first | +| **A plain script** | Reading the rest of the HTML while it downloads and runs | Use `defer` or `async` | +| **A late-found important file** | The picture or font arriving when it is needed | `preload`, or put it in the HTML | +| **A new connection to another server** | The first request to it | `preconnect` | +| **A large, unneeded file** | Bandwidth for everything else | Smaller files, lazy loading, responsive images | + +## What We Measure + +Three numbers, called the **Core Web Vitals,** describe the experience people have: + +| Metric | What it measures | Good means | +| :-- | :-- | :-- | +| **LCP** | How soon the biggest visible thing appears | Up to 2.5 seconds | +| **INP** | How quickly the page responds when you use it | Up to 200 milliseconds | +| **CLS** | How much the layout jumps while loading | Up to 0.1 | + +These are the thresholds Google published at the time of writing, and they may change. Check your own pages, and test your understanding: + + +<> + + + + +### Lab data and field data + +- **Lab data** comes from a test you run, on one device, on one connection: Lighthouse, PageSpeed Insights, WebPageTest, and the browser's developer tools. It is repeatable, and good for finding causes. +- **Field data** comes from real visitors on their real devices: Search Console's Core Web Vitals report, and real user monitoring. It shows what people actually experience, and is what matters most. + +A good habit is to **find** problems with lab tests, and **confirm** with field data. + +## Which Tool for Which Job? + +The lessons in this section each add a tool. Match them to the problems they solve: + + +<> + + + + +## What Is in This Section + +### Foundations + +
+
+
+

HTML Performance Basics

+

What makes pages slow, and the HTML habits that make them fast.

+ +
+
+
+
+

Critical Resources

+

Finding the few files that must arrive first, and putting them first.

+ +
+
+
+
+

Core Web Vitals

+

LCP, INP, and CLS: what they measure, and how to improve them.

+ +
+
+
+ +### Resource Hints + +
+
+
+

Resource Hints

+

How preload, prefetch, preconnect, and dns-prefetch differ.

+ +
+
+
+
+

Preload

+

Fetching a file you know the page needs, earlier than the browser would.

+ +
+
+
+
+

Prefetch

+

Quietly fetching what the next page will need.

+ +
+
+
+
+

DNS Prefetch

+

Looking up another site's address ahead of time.

+ +
+
+
+
+

Preconnect

+

Opening a connection to another origin before you need it.

+ +
+
+
+ +### Loading Scripts + +
+
+
+

Script Loading

+

How classic, module, defer, and async scripts affect the page.

+ +
+
+
+
+

defer

+

Download early, run after the page is parsed, in order.

+ +
+
+
+
+

async

+

Download early, run as soon as ready, in any order.

+ +
+
+
+ +### Images + +
+
+
+

Lazy Loading

+

Waiting to load images and frames until they are needed.

+ +
+
+
+
+

Responsive Images and Performance

+

Sending each device an image of the right size.

+ +
+
+
+## How to Measure + +You cannot improve what you do not measure. These tools are free, and a good place to start: + +- **Lighthouse,** built into Chrome DevTools. It tests one page, and suggests fixes. +- **PageSpeed Insights.** It runs Lighthouse, and also shows **field data** for popular pages, where available. +- **Chrome DevTools, Network panel.** It shows every file, when it started, and how long it took (the waterfall). +- **Chrome DevTools, Performance panel.** It shows what the browser did, and when. +- **WebPageTest.** It tests from different places, devices, and connection speeds. +- **Search Console's Core Web Vitals report.** It shows real-visitor data for your whole site. + +Habits that make your tests honest: + +1. **Throttle the network and the CPU** in DevTools. Your development machine is probably much faster than your visitors'. +2. **Test a cold load** (an empty cache), and a repeat load. +3. **Test on a real mid-range phone** when you can. +4. **Run each test several times,** since results vary. +5. **Change one thing at a time,** so you know what helped. + +## Five Habits to Start Today + +1. **Send less.** Smaller images, fewer scripts, and no files you do not use. +2. **Put the important things first.** The main image, the main styles, and the main script. +3. **Do not block.** Use `defer` or `async` for scripts. +4. **Reserve space.** Give images and embeds a `width` and `height`, so the page does not jump. +5. **Measure.** Check Lighthouse and your field data, not your feelings. + +## Performance Checklist for Any Page + +- [ ] The page works with a slow connection and a slow phone +- [ ] CSS in the head is small, and only what is needed first +- [ ] Scripts use `defer` or `async`, not plain blocking tags +- [ ] The main image is found early, not lazy-loaded, and not too big +- [ ] Images below the first screen use `loading="lazy"` +- [ ] Every image and embed has a `width` and `height` +- [ ] Images use modern formats and responsive sizes +- [ ] Connections to other servers use `preconnect` only when they are needed +- [ ] No unused scripts, styles, or fonts +- [ ] Core Web Vitals checked in the lab and in the field + +## Security and Privacy Notes + +- **Third-party scripts are a risk.** Every analytics, chat, ad, or widget script runs with the full power of your page. A compromised or careless provider can read what visitors type, or change your page. Use only what you need, from providers you trust, and consider the `integrity` attribute for files that do not change. +- **Hints contact other servers early.** `preconnect` and `dns-prefetch` make the visitor's browser contact a third party before the visitor does anything, which shares their address and some details with that party. If you need consent first (for example for analytics or advertising services), do not hint those hosts until consent is given. This is not legal advice, so check the rules that apply to you. +- **Preload and prefetch only public resources.** Do not hint at private, personal, or one-time URLs. A prefetched address is fetched whether or not the visitor ever goes there, and can trigger actions or appear in logs. +- **Measuring collects data.** Real user monitoring records things about your visitors' devices and pages. Collect the least you need, say what you collect, and respect consent rules. +- **Caching can leak.** Do not let private or personalised pages be stored by shared caches. Check your cache headers. +- **A content security policy** can limit which hosts can load scripts, which also stops a hint pointing at an unexpected host from helping an attacker. + +## Global Audience Notes + +- **Many of your readers are on slow networks and modest phones.** Design for them first, and everyone else gets a bonus. +- **Data is expensive in many places.** Heavy pages and autoplaying media can cost people real money. Offer lighter options, and respect the `Save-Data` preference where supported. +- **Distance adds delay.** A **content delivery network (CDN)** serves files from near your readers, and is one of the biggest wins for a worldwide audience. +- **Scripts add weight.** Fonts for Chinese, Japanese, and Korean can be very large, so use subsets, system fonts, or only the weights you need. +- **Third-party services may be slow, or blocked, in some countries.** A page should still load if a widget does not. +- **Test from other places.** WebPageTest and similar tools can run from different regions and on different connections. +- **Use plain, small, well-compressed files,** so the page works on older browsers and devices too. + +## Common Mistakes to Avoid + +1. **Optimising before measuring.** +2. **Lazy-loading the main image,** which makes the page seem slower. +3. **Plain scripts in the head** that block the page. +4. **Preloading everything,** which then competes with the files that matter. +5. **Preconnecting to many hosts** you barely use. +6. **Huge images,** displayed small. +7. **No `width` and `height`,** so the layout jumps. +8. **Too many third-party scripts.** +9. **Testing only on a fast machine and a fast connection.** +10. **Trusting a single score** instead of looking at what the page does. +11. **Making a change, and never checking** that it helped. +12. **Forgetting repeat visits,** where caching changes everything. + +## Practice Time + +Try these in your browser and your editor: + +1. Use the loading timeline. Which single change helps the most? Which does nothing? +2. Turn on "loading=lazy" for the hero image. What happens to the LCP, and why? +3. Take the Core Web Vitals quiz. Which number surprised you? +4. Take the tool matcher quiz. Which two tools did you confuse? +5. Run Lighthouse on a page you built, and read its three biggest suggestions. +6. Open DevTools, then the Network panel, and reload. Which file takes the longest? +7. Throttle the network to a slow setting, and reload. What do you see first? +8. Find a plain script in the head of a page you built, and add `defer`. What changes? +9. Add `width` and `height` to every image on a page, and watch for layout jumps. +10. Bonus: run PageSpeed Insights on a site you use daily. What does the field data say? + + +
+ +## Quick Recap + +Click a question to check your answer. + +
+1. What is web performance? + +How quickly and smoothly a page loads and responds. Much of it is decided by which files the HTML asks for, in what order, and with which attributes. + +
+ +
+2. What blocks a page from appearing? + +Stylesheets in the head, and plain scripts that stop the browser reading the rest of the HTML while they download and run. Late-found important files and slow connections also delay things. + +
+ +
+3. What are the three Core Web Vitals? + +LCP (how soon the biggest thing appears), INP (how quickly the page responds), and CLS (how much the layout jumps). + +
+ +
+4. What is the difference between lab data and field data? + +Lab data comes from a test you run on one device and connection. Field data comes from real visitors. Find problems with lab tests, and confirm with field data. + +
+ +
+5. Which scripts should use defer or async? + +Almost all of them. Use `defer` for scripts that need the page and must run in order, and `async` for independent scripts such as analytics. + +
+ +
+6. Why should you test with throttling? + +Your own machine and network are probably much faster than your visitors'. Throttling shows what a slower phone and connection experience. + +
+ +## Key Takeaways + +- **Performance is about removing waiting:** for files to be found, fetched, and run. +- **HTML decides a lot:** what you load, in what order, and how. +- **Do not block the page.** Use `defer` and `async`. +- **Help the browser find important files early,** with the right element or a preload. +- **Do not load what is not needed,** and do not send more than the screen needs. +- **Reserve space** for images and embeds with `width` and `height`. +- **Measure in the lab and in the field,** and check that each change helped. +- **Design for slow networks and modest phones first.** + +Start with [HTML Performance Basics](./performance/html-performance-basics), and work through the lessons in order. + +``` + β”œβ”€β”€ 15-performance/ + β”‚ β”œβ”€β”€ index.mdx + β”‚ β”œβ”€β”€ + β”‚ β”œβ”€β”€ resource-hints.mdx + β”‚ β”œβ”€β”€ preload.mdx + β”‚ β”œβ”€β”€ prefetch.mdx + β”‚ β”œβ”€β”€ dns-prefetch.mdx + β”‚ β”œβ”€β”€ preconnect.mdx + β”‚ β”œβ”€β”€ lazy-loading.mdx + β”‚ β”œβ”€β”€ script-loading.mdx + β”‚ β”œβ”€β”€ defer.mdx + β”‚ β”œβ”€β”€ async.mdx + β”‚ β”œβ”€β”€ responsive-images-performance.mdx + β”‚ β”œβ”€β”€ critical-resources.mdx + β”‚ └── core-web-vitals.mdx +``` \ No newline at end of file diff --git a/tutorials/html/index.mdx b/tutorials/html/index.mdx index 7053044b2..e21f3e0a7 100644 --- a/tutorials/html/index.mdx +++ b/tutorials/html/index.mdx @@ -24,6 +24,14 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import AdsComponent from '@site/src/components/AdsComponent'; + + **Learn HTML from the ground up and build real web pages for the modern web.** HTML (HyperText Markup Language) is the standard markup language used to structure content on the web. Whether you want to become a frontend developer, full-stack developer, software engineer, designer who codes, or simply understand how websites work, HTML is one of the most important technologies to learn.