diff --git a/docs/projects/index.mdx b/docs/projects/index.mdx index e22e291..1cd6968 100644 --- a/docs/projects/index.mdx +++ b/docs/projects/index.mdx @@ -7,6 +7,7 @@ description: "Practical projects you can build with Python — installing it for --- import ProjectChooser from '@site/src/components/ProjectChooser'; +import ProjectsListingJsonLd from '@site/src/components/ProjectsListingJsonLd'; import {mergeProjectMeta} from '@site/src/data/projects'; # 🌍 Real-World Projects @@ -193,3 +194,5 @@ They're optional and ungraded. Browse them any time — each project's intro say }, ])} /> + + diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 5a1bd43..a30250c 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -13,6 +13,10 @@ const config: Config = { future: { v4: true, + // Git-backed "last updated" timestamps — feeds the sitemap's per-page + // (a real freshness signal for crawlers) and a "Last updated" + // line on docs pages. + experimental_vcs: true, }, url: 'https://pyda-course.online', @@ -26,26 +30,75 @@ const config: Config = { tagName: 'meta', attributes: {name: 'author', content: 'Abderrahim Adrabi'}, }, + { + tagName: 'meta', + attributes: {property: 'og:site_name', content: 'Python & Data Analysis Course'}, + }, { tagName: 'script', attributes: {type: 'application/ld+json'}, innerHTML: JSON.stringify({ '@context': 'https://schema.org', - '@type': 'Course', - name: 'Python & Data Analysis Course', - description: - 'A free, browser-based Python and data analysis course covering Python fundamentals and pandas/EDA across two 5-week sections, each with Normal and Hard tracks.', - author: { - '@type': 'Person', - name: 'Abderrahim Adrabi', - }, - provider: { - '@type': 'Organization', - name: 'Python & Data Analysis Course', - sameAs: 'https://github.com/abderrahim-lectures/python-data-analysis-course', - }, - isAccessibleForFree: true, - inLanguage: ['en', 'ar', 'es', 'fr'], + '@graph': [ + { + '@type': 'WebSite', + '@id': 'https://pyda-course.online/#website', + url: 'https://pyda-course.online/', + name: 'Python & Data Analysis Course', + description: + 'A free, browser-based Python and data analysis course — no installs required.', + inLanguage: ['en', 'ar', 'es', 'fr'], + publisher: {'@id': 'https://pyda-course.online/#organization'}, + }, + { + '@type': 'Organization', + '@id': 'https://pyda-course.online/#organization', + name: 'Python & Data Analysis Course', + url: 'https://pyda-course.online/', + logo: { + '@type': 'ImageObject', + url: 'https://pyda-course.online/img/logo.svg', + }, + sameAs: ['https://github.com/abderrahim-lectures/python-data-analysis-course'], + }, + { + '@type': 'Course', + '@id': 'https://pyda-course.online/#course', + name: 'Python & Data Analysis Course', + description: + 'A free, browser-based Python and data analysis course covering Python fundamentals and pandas/EDA across two 5-week sections, each with Normal and Hard tracks, plus a growing library of real-world projects to build after you graduate from the playground.', + url: 'https://pyda-course.online/', + isAccessibleForFree: true, + inLanguage: ['en', 'ar', 'es', 'fr'], + learningResourceType: 'Course', + educationalLevel: 'Beginner', + coursePrerequisites: 'None — no prior programming experience needed.', + teaches: [ + 'Python fundamentals', + 'Pandas and data analysis', + 'Exploratory data analysis', + 'Building real-world Python projects', + ], + offers: { + '@type': 'Offer', + category: 'Free', + price: '0', + priceCurrency: 'USD', + }, + provider: {'@id': 'https://pyda-course.online/#organization'}, + author: { + '@type': 'Person', + name: 'Abderrahim Adrabi', + }, + hasCourseInstance: { + '@type': 'CourseInstance', + courseMode: 'Online', + courseWorkload: 'PT20H', + inLanguage: ['en', 'ar', 'es', 'fr'], + isAccessibleForFree: true, + }, + }, + ], }), }, ], @@ -77,6 +130,9 @@ const config: Config = { sidebarPath: './sidebars.ts', editUrl: 'https://github.com/abderrahim-lectures/python-data-analysis-course/tree/main/', + // Git-backed "Last updated" line on docs pages + the data that feeds + // the sitemap's per-page (see future.experimental_vcs). + showLastUpdateTime: true, remarkPlugins: [require('remark-math')], rehypePlugins: [require('rehype-katex')], }, @@ -91,6 +147,8 @@ const config: Config = { ignorePatterns: ['/share'], changefreq: 'weekly', priority: 0.5, + // Include a per-page lastmod so crawlers see when each doc changes. + lastmod: 'datetime', }, gtag: { trackingID: 'G-FTR585C5BX', diff --git a/i18n/ar/code.json b/i18n/ar/code.json index 47fd936..78b88a9 100644 --- a/i18n/ar/code.json +++ b/i18n/ar/code.json @@ -965,5 +965,96 @@ "homepage.projects.wordleClone.summary": { "message": "ابنِ لعبة Wordle حقيقية في الطرفية من الصفر: تغذية راجعة صحيحة بالتخمين، وقائمة كلمات مخصصة، وتتبّع إحصائيات دائم عبر الجلسات.", "description": "Homepage project card summary" + }, + "homepage.hero.statWeeks": { + "message": "أسابيع", + "description": "Homepage hero stat: weeks" + }, + "homepage.hero.statProjects": { + "message": "مشاريع من العالم الحقيقي", + "description": "Homepage hero stat: projects" + }, + "homepage.hero.statInstalls": { + "message": "تثبيتات للبدء", + "description": "Homepage hero stat: installs" + }, + "homepage.projects.completed": { + "message": "مكتمل", + "description": "Homepage project card completed label" + }, + "homepage.projects.filterAll": { + "message": "الكل", + "description": "Project filter chip: all projects" + }, + "capstoneChooser.filterAll": { + "message": "الكل" + }, + "progressPage.overall.heading": { + "message": "التقدم العام" + }, + "progressPage.overall.detail": { + "message": "{weeks} مكتملة و{projects} مبنية" + }, + "progressPage.overall.weeks": { + "message": "أسابيع" + }, + "progressPage.overall.projects": { + "message": "مشاريع" + }, + "progressPage.overall.empty": { + "message": "اختر مسارًا في بايثون 101 أو تحليل البيانات لبدء تتبع تقدمك." + }, + "projectGallery.filtersLabel": { + "message": "تصفية المشاريع" + }, + "projectGallery.filterAll": { + "message": "الكل", + "description": "Project filter chip: all projects" + }, + "projectGallery.favoritesFilter": { + "message": "المفضلة", + "description": "Project filter chip: favorites only" + }, + "projectGallery.favorite.add": { + "message": "أضف إلى المفضلة" + }, + "projectGallery.favorite.remove": { + "message": "أزل من المفضلة" + }, + "projectGallery.showing": { + "message": "عرض {shown} من {total}", + "description": "Result counter: how many of the total are currently rendered" + }, + "projectGallery.empty": { + "message": "لا توجد مشاريع تطابق عوامل التصفية.", + "description": "Empty state when no projects match the active filters" + }, + "projectGallery.end": { + "message": "لقد شاهدت جميع المشاريع الـ{total}.", + "description": "End of list message with the total project count" + }, + "projectGallery.completed": { + "message": "مكتمل", + "description": "Project card completed label" + }, + "projectLevel.beginner": { + "message": "مبتدئ", + "description": "Project level label" + }, + "projectLevel.intermediate": { + "message": "متوسط", + "description": "Project level label" + }, + "projectLevel.advanced": { + "message": "متقدم", + "description": "Project level label" + }, + "projectGallery.toolsLabel": { + "message": "الأدوات", + "description": "Project card tools row aria-label" + }, + "homepage.hero.runChip": { + "message": "شغّل في متصفحك", + "description": "Homepage hero floating chip" } } diff --git a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx index 145a2d5..e6bcc6d 100644 --- a/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/ar/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -7,6 +7,7 @@ description: "مشاريع عملية يمكنك بناؤها باستخدام P --- import ProjectChooser from '@site/src/components/ProjectChooser'; +import ProjectsListingJsonLd from '@site/src/components/ProjectsListingJsonLd'; import {mergeProjectMeta} from '@site/src/data/projects'; # 🌍 مشاريع من العالم الحقيقي @@ -193,3 +194,5 @@ import {mergeProjectMeta} from '@site/src/data/projects'; }, ])} /> + + diff --git a/i18n/es/code.json b/i18n/es/code.json index e6f2f72..a330f87 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -965,5 +965,96 @@ "homepage.projects.wordleClone.summary": { "message": "Construye un juego Wordle real de terminal desde cero: retroalimentación correcta de acierto, una lista de palabras personalizada, y seguimiento persistente de estadísticas entre sesiones.", "description": "Homepage project card summary" + }, + "homepage.hero.statWeeks": { + "message": "semanas", + "description": "Homepage hero stat: weeks" + }, + "homepage.hero.statProjects": { + "message": "proyectos del mundo real", + "description": "Homepage hero stat: projects" + }, + "homepage.hero.statInstalls": { + "message": "instalaciones para empezar", + "description": "Homepage hero stat: installs" + }, + "homepage.projects.completed": { + "message": "Completado", + "description": "Homepage project card completed label" + }, + "homepage.projects.filterAll": { + "message": "Todos", + "description": "Project filter chip: all projects" + }, + "capstoneChooser.filterAll": { + "message": "Todos" + }, + "progressPage.overall.heading": { + "message": "Progreso general" + }, + "progressPage.overall.detail": { + "message": "{weeks} completadas y {projects} construidos" + }, + "progressPage.overall.weeks": { + "message": "semanas" + }, + "progressPage.overall.projects": { + "message": "proyectos" + }, + "progressPage.overall.empty": { + "message": "Elige un track en Python 101 o Análisis de Datos para empezar a registrar tu progreso." + }, + "projectGallery.filtersLabel": { + "message": "Filtrar proyectos" + }, + "projectGallery.filterAll": { + "message": "Todos", + "description": "Project filter chip: all projects" + }, + "projectGallery.favoritesFilter": { + "message": "Favoritos", + "description": "Project filter chip: favorites only" + }, + "projectGallery.favorite.add": { + "message": "Añadir a favoritos" + }, + "projectGallery.favorite.remove": { + "message": "Quitar de favoritos" + }, + "projectGallery.showing": { + "message": "Mostrando {shown} de {total}", + "description": "Result counter: how many of the total are currently rendered" + }, + "projectGallery.empty": { + "message": "Ningún proyecto coincide con tus filtros.", + "description": "Empty state when no projects match the active filters" + }, + "projectGallery.end": { + "message": "Has visto los {total} proyectos.", + "description": "End of list message with the total project count" + }, + "projectGallery.completed": { + "message": "Completado", + "description": "Project card completed label" + }, + "projectLevel.beginner": { + "message": "Principiante", + "description": "Project level label" + }, + "projectLevel.intermediate": { + "message": "Intermedio", + "description": "Project level label" + }, + "projectLevel.advanced": { + "message": "Avanzado", + "description": "Project level label" + }, + "projectGallery.toolsLabel": { + "message": "Herramientas", + "description": "Project card tools row aria-label" + }, + "homepage.hero.runChip": { + "message": "Ejecuta en tu navegador", + "description": "Homepage hero floating chip" } } diff --git a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx index 168e920..2c8912c 100644 --- a/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/es/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -7,6 +7,7 @@ description: "Proyectos prácticos que puedes construir con Python — instálal --- import ProjectChooser from '@site/src/components/ProjectChooser'; +import ProjectsListingJsonLd from '@site/src/components/ProjectsListingJsonLd'; import {mergeProjectMeta} from '@site/src/data/projects'; # 🌍 Proyectos del mundo real @@ -193,3 +194,5 @@ Son opcionales y no calificados. Explóralos en cualquier momento — la introdu }, ])} /> + + diff --git a/i18n/fr/code.json b/i18n/fr/code.json index d5b14c9..b474e1f 100644 --- a/i18n/fr/code.json +++ b/i18n/fr/code.json @@ -965,5 +965,96 @@ "homepage.projects.wordleClone.summary": { "message": "Construis un vrai jeu Wordle en terminal de zéro : retour correct sur chaque essai, une liste de mots personnalisée, et un suivi persistant des statistiques entre sessions.", "description": "Homepage project card summary" + }, + "homepage.hero.statWeeks": { + "message": "semaines", + "description": "Homepage hero stat: weeks" + }, + "homepage.hero.statProjects": { + "message": "projets concrets", + "description": "Homepage hero stat: projects" + }, + "homepage.hero.statInstalls": { + "message": "installations pour démarrer", + "description": "Homepage hero stat: installs" + }, + "homepage.projects.completed": { + "message": "Terminé", + "description": "Homepage project card completed label" + }, + "homepage.projects.filterAll": { + "message": "Tous", + "description": "Project filter chip: all projects" + }, + "capstoneChooser.filterAll": { + "message": "Tous" + }, + "progressPage.overall.heading": { + "message": "Progression globale" + }, + "progressPage.overall.detail": { + "message": "{weeks} terminées et {projects} réalisés" + }, + "progressPage.overall.weeks": { + "message": "semaines" + }, + "progressPage.overall.projects": { + "message": "projets" + }, + "progressPage.overall.empty": { + "message": "Choisissez un parcours en Python 101 ou Analyse de Données pour commencer à suivre votre progression." + }, + "projectGallery.filtersLabel": { + "message": "Filtrer les projets" + }, + "projectGallery.filterAll": { + "message": "Tous", + "description": "Project filter chip: all projects" + }, + "projectGallery.favoritesFilter": { + "message": "Favoris", + "description": "Project filter chip: favorites only" + }, + "projectGallery.favorite.add": { + "message": "Ajouter aux favoris" + }, + "projectGallery.favorite.remove": { + "message": "Retirer des favoris" + }, + "projectGallery.showing": { + "message": "Affichage de {shown} sur {total}", + "description": "Result counter: how many of the total are currently rendered" + }, + "projectGallery.empty": { + "message": "Aucun projet ne correspond à vos filtres.", + "description": "Empty state when no projects match the active filters" + }, + "projectGallery.end": { + "message": "Vous avez vu les {total} projets.", + "description": "End of list message with the total project count" + }, + "projectGallery.completed": { + "message": "Terminé", + "description": "Project card completed label" + }, + "projectLevel.beginner": { + "message": "Débutant", + "description": "Project level label" + }, + "projectLevel.intermediate": { + "message": "Intermédiaire", + "description": "Project level label" + }, + "projectLevel.advanced": { + "message": "Avancé", + "description": "Project level label" + }, + "projectGallery.toolsLabel": { + "message": "Outils", + "description": "Project card tools row aria-label" + }, + "homepage.hero.runChip": { + "message": "Exécuter dans votre navigateur", + "description": "Homepage hero floating chip" } } diff --git a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx index 038ed7f..0fa79ae 100644 --- a/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx +++ b/i18n/fr/docusaurus-plugin-content-docs/current/projects/index.mdx @@ -7,6 +7,7 @@ description: "Des projets concrets à construire avec Python — installez-le po --- import ProjectChooser from '@site/src/components/ProjectChooser'; +import ProjectsListingJsonLd from '@site/src/components/ProjectsListingJsonLd'; import {mergeProjectMeta} from '@site/src/data/projects'; # 🌍 Projets concrets @@ -193,3 +194,5 @@ Ils sont optionnels et non notés. Parcourez-les à tout moment — l'introducti }, ])} /> + + diff --git a/src/components/ProjectChooser/index.tsx b/src/components/ProjectChooser/index.tsx index 246f557..49c8b54 100644 --- a/src/components/ProjectChooser/index.tsx +++ b/src/components/ProjectChooser/index.tsx @@ -1,82 +1,17 @@ -import React from 'react'; -import Link from '@docusaurus/Link'; -import Translate from '@docusaurus/Translate'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import {useLocalStorage} from '@site/src/hooks/useLocalStorage'; -import {STORAGE_KEYS} from '@site/src/utils/storageKeys'; -import {formatProjectDate} from '@site/src/data/projects'; -import type {ProjectId, ProjectProgressMap} from '@site/src/types/progress'; -import styles from './styles.module.css'; +import ProjectGallery, {type GalleryProject} from '@site/src/components/ProjectGallery'; -export interface ProjectInfo { - id: ProjectId; - /** ISO "YYYY-MM" date — drives newest-first sort and is shown, formatted, on the card. */ - date: string; - title: string; - summary: string; - url: string; - tags: string[]; -} +/** + * Backwards-compatible entry point for the docs projects page + * (docs/projects/index.mdx + its translated siblings): the whole browser + * lives in now, so this is just an alias. The extra + * indirection is kept so the four MDX files don't each need touching. + */ +export interface ProjectInfo extends GalleryProject {} interface Props { projects: ProjectInfo[]; } -/** - * Lists every real-world project, newest first. Freely browsable any time — - * no completion gate. Each project's own completion is still tracked - * separately (optional, student-driven) so a project already built shows a - * checkmark on return visits. - */ export default function ProjectChooser({projects}: Props): React.JSX.Element { - const [progress] = useLocalStorage(STORAGE_KEYS.projectProgress, {}); - const { - i18n: {currentLocale}, - } = useDocusaurusContext(); - - // Newest date first; same-day ties break alphabetically by id, so the - // order is identical everywhere this list is rendered (docs page, - // homepage) regardless of the order projects happen to be passed in. - const sorted = [...projects].sort((a, b) => { - if (a.date !== b.date) { - return a.date < b.date ? 1 : -1; - } - return a.id < b.id ? -1 : 1; - }); - - return ( -
- {sorted.map((project) => { - const completed = progress[project.id] ?? false; - return ( - -

- {completed && ( - - )} - {project.title} -

-

{formatProjectDate(project.date, currentLocale)}

-

{project.summary}

- {project.tags.length > 0 && ( -
- {project.tags.map((tag) => ( - - {tag} - - ))} -
- )} - {completed && ( -

- Completed -

- )} - - ); - })} -
- ); + return ; } diff --git a/src/components/ProjectChooser/styles.module.css b/src/components/ProjectChooser/styles.module.css deleted file mode 100644 index 0645ce0..0000000 --- a/src/components/ProjectChooser/styles.module.css +++ /dev/null @@ -1,59 +0,0 @@ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 1rem; - margin: 1.5rem 0; -} - -.card { - border: 1px solid var(--ifm-color-emphasis-200); - border-radius: var(--pda-radius-lg); - padding: 1.25rem; - background: var(--pda-surface); - display: flex; - flex-direction: column; - color: var(--ifm-font-color-base); - text-decoration: none; -} - -.card:hover { - border-color: var(--pda-accent); - text-decoration: none; - color: var(--ifm-font-color-base); -} - -.card h3 { - margin-top: 0; -} - -.checkmark { - color: var(--pda-accent); -} - -.date { - font-size: 0.85rem; - color: var(--ifm-color-emphasis-600); - margin: -0.5rem 0 0.75rem; -} - -.completedLabel { - font-weight: 600; - color: var(--pda-accent); - margin-bottom: 0; -} - -.tags { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - margin-top: 0.5rem; -} - -.tag { - font-size: 0.75rem; - font-weight: 600; - padding: 0.15rem 0.55rem; - border-radius: 999px; - background: var(--pda-accent-soft); - color: var(--pda-accent); -} diff --git a/src/components/ProjectGallery/index.tsx b/src/components/ProjectGallery/index.tsx new file mode 100644 index 0000000..0d32c9d --- /dev/null +++ b/src/components/ProjectGallery/index.tsx @@ -0,0 +1,295 @@ +import React, {useEffect, useMemo, useRef, useState, type ReactNode} from 'react'; +import Link from '@docusaurus/Link'; +import Translate, {translate} from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import clsx from 'clsx'; +import {useLocalStorage} from '@site/src/hooks/useLocalStorage'; +import {useFavorites} from '@site/src/hooks/useFavorites'; +import {STORAGE_KEYS} from '@site/src/utils/storageKeys'; +import {formatProjectDate, PROJECT_LEVELS, type ProjectLevel} from '@site/src/data/projects'; +import ProjectArt from '@site/src/data/projectArt'; +import type {ProjectId, ProjectProgressMap} from '@site/src/types/progress'; +import styles from './styles.module.css'; + +export interface GalleryProject { + id: ProjectId; + /** ISO "YYYY-MM" date — drives newest-first sort and the card's date eyebrow. */ + date: string; + url: string; + tags: string[]; + /** Difficulty bucket — shown as a badge and filterable. */ + level: ProjectLevel; + /** Compact tool names rendered as pills under the summary. */ + tools: string[]; + /** Already-translated title/summary (a element on the homepage, + * a plain string on the docs projects page). */ + title: ReactNode; + summary: ReactNode; +} + +/** English defaults for the level badges; real strings come from code.json per locale. */ +const LEVEL_LABELS: Record = { + beginner: 'Beginner', + intermediate: 'Intermediate', + advanced: 'Advanced', +}; + +interface Props { + projects: GalleryProject[]; + /** How many cards render on first paint (server + first client render). */ + initialCount?: number; + /** How many more load each time the sentinel scrolls into view. */ + pageSize?: number; +} + +/** + * Shared "real-world projects" browser used by both the docs projects page and + * the homepage. Stays fast with 200+ projects because: + * - cards render in batches via an IntersectionObserver sentinel (infinite scroll), + * never all at once; + * - offscreen cards get `content-visibility: auto` so the browser skips their layout; + * - progress and favorites are read ONCE here, not once per card; + * - tag metadata comes from a Map lookup, not a per-card find. + * Favorites are shared across both surfaces through the same localStorage key. + */ +export default function ProjectGallery({projects, initialCount = 12, pageSize = 12}: Props): React.JSX.Element { + const { + i18n: {currentLocale}, + } = useDocusaurusContext(); + const [progress] = useLocalStorage(STORAGE_KEYS.projectProgress, {}); + const {has: isFavorite, toggle: toggleFavorite, count: favoriteCount} = useFavorites(); + + // Newest date first; same-day ties break alphabetically by id, so the order + // is identical everywhere (docs page, homepage) regardless of input order. + const sorted = useMemo( + () => + [...projects].sort((a, b) => { + if (a.date !== b.date) { + return a.date < b.date ? 1 : -1; + } + return a.id < b.id ? -1 : 1; + }), + [projects], + ); + + const tags = useMemo(() => { + const seen = new Set(); + for (const p of projects) { + for (const tag of p.tags) { + seen.add(tag); + } + } + return [...seen]; + }, [projects]); + + const [activeTag, setActiveTag] = useState(null); + const [activeLevel, setActiveLevel] = useState(null); + const [favoritesOnly, setFavoritesOnly] = useState(false); + const [visibleCount, setVisibleCount] = useState(initialCount); + + const filtered = useMemo(() => { + let list = sorted; + if (activeTag !== null) { + list = list.filter((p) => p.tags.includes(activeTag)); + } + if (activeLevel !== null) { + list = list.filter((p) => p.level === activeLevel); + } + if (favoritesOnly) { + list = list.filter((p) => isFavorite(p.id)); + } + return list; + }, [sorted, activeTag, activeLevel, favoritesOnly, isFavorite]); + + // A filter change starts a fresh scroll batch. + useEffect(() => { + setVisibleCount(initialCount); + }, [activeTag, activeLevel, favoritesOnly, initialCount]); + + const shown = Math.min(visibleCount, filtered.length); + const hasMore = shown < filtered.length; + const visible = filtered.slice(0, shown); + + const sentinelRef = useRef(null); + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || typeof IntersectionObserver === 'undefined' || !hasMore) { + return; + } + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setVisibleCount((c) => Math.min(c + pageSize, filtered.length)); + } + } + }, + // Preload a screen-and-a-bit ahead so scrolling feels continuous. + {rootMargin: '800px 0px'}, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, pageSize, filtered.length]); + + return ( + <> +
+ + + {PROJECT_LEVELS.map((level) => ( + + ))} + {tags.map((tag) => ( + + ))} +
+ +

+ + {'Showing {shown} of {total}'} + +

+ + {filtered.length === 0 ? ( +

+ + No projects match your filters. + +

+ ) : ( +
+ {visible.map((project) => { + const completed = progress[project.id] ?? false; + const favorite = isFavorite(project.id); + return ( +
+ +
+ + + {translate({id: `projectLevel.${project.level}`, message: LEVEL_LABELS[project.level]})} + +
+
+

{formatProjectDate(project.date, currentLocale)}

+

{project.title}

+

{project.summary}

+ {project.tools.length > 0 && ( +
+ {project.tools.map((tool) => ( + + {tool} + + ))} +
+ )} + {project.tags.length > 0 && ( +
+ {project.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {completed && ( +

+ + Completed + +

+ )} +
+ + +
+ ); + })} +
+ )} + + {hasMore ? ( + ); diff --git a/src/pages/progress.module.css b/src/pages/progress.module.css new file mode 100644 index 0000000..216f662 --- /dev/null +++ b/src/pages/progress.module.css @@ -0,0 +1,94 @@ +.overall { + padding: 1.75rem; + margin-bottom: 2rem; + border-radius: var(--pda-radius-lg); + background: var(--pda-surface); + border: 1px solid var(--ifm-color-emphasis-200); +} + +.overallInner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 1.75rem; +} + +@media screen and (min-width: 640px) { + .overallInner { + justify-content: flex-start; + } +} + +/* Circular progress ring, drawn with a conic gradient; the inner disc + is punched out so the ring matches the card background. */ +.ring { + position: relative; + flex: 0 0 auto; + width: 7.5rem; + height: 7.5rem; + border-radius: 50%; + background: conic-gradient( + var(--ifm-color-primary) 0%, + var(--pda-accent) calc(var(--ring-pct) * 1), + var(--ifm-color-emphasis-200) calc(var(--ring-pct) * 1) 100% + ); +} + +.ringInner { + position: absolute; + inset: 0.625rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--pda-surface); +} + +.ringPercent { + font-size: 1.6rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--ifm-color-primary); + font-variant-numeric: tabular-nums; +} + +.overallBody { + flex: 1 1 16rem; + min-width: 0; +} + +.overallHeader { + margin-bottom: 0.75rem; +} + +.overallHeader h2 { + margin: 0; + font-size: 1.35rem; + text-wrap: balance; +} + +.barTrack { + height: 0.6rem; + margin-bottom: 0.85rem; + border-radius: 999px; + background: var(--ifm-color-emphasis-200); + overflow: hidden; +} + +.barFill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--ifm-color-primary), var(--pda-accent)); + transition: width 0.4s ease; +} + +.overallDetail { + margin: 0; + color: var(--ifm-color-emphasis-700); +} + +.overallEmpty { + margin: 0; + color: var(--ifm-color-emphasis-700); +} diff --git a/src/pages/progress.tsx b/src/pages/progress.tsx index f632338..42ce4a0 100644 --- a/src/pages/progress.tsx +++ b/src/pages/progress.tsx @@ -1,11 +1,98 @@ -import type {ReactNode} from 'react'; +import type {CSSProperties, ReactNode} from 'react'; import Link from '@docusaurus/Link'; +import Head from '@docusaurus/Head'; import Translate, {translate} from '@docusaurus/Translate'; import Layout from '@theme/Layout'; import BadgeCase from '@site/src/components/BadgeCase'; import ShareProgress from '@site/src/components/ShareProgress'; import DataTransfer from '@site/src/components/DataTransfer'; +import {useLocalStorage} from '@site/src/hooks/useLocalStorage'; import {useCourseComplete} from '@site/src/hooks/useUnlockCondition'; +import {PROJECTS} from '@site/src/data/projects'; +import {STORAGE_KEYS} from '@site/src/utils/storageKeys'; +import {getChosenWeeksPartial} from '@site/src/utils/weeks'; +import type {PerSectionTrack, ProgressMap, ProjectProgressMap} from '@site/src/types/progress'; +import styles from './progress.module.css'; + +function OverallProgress(): React.JSX.Element { + const [progress] = useLocalStorage(STORAGE_KEYS.progress, {}); + const [tracks] = useLocalStorage(STORAGE_KEYS.track, {}); + const [projectProgress] = useLocalStorage(STORAGE_KEYS.projectProgress, {}); + + const chosenWeeks = getChosenWeeksPartial(tracks); + const weeksDone = chosenWeeks.filter((w) => progress[w.weekId]).length; + const weeksTotal = chosenWeeks.length; + const projectsDone = PROJECTS.filter((p) => projectProgress[p.id]).length; + const projectsTotal = PROJECTS.length; + + const tracked = weeksTotal > 0; + const itemsDone = weeksDone + projectsDone; + const itemsTotal = weeksTotal + projectsTotal; + const percent = tracked && itemsTotal > 0 ? Math.round((itemsDone / itemsTotal) * 100) : 0; + + if (!tracked) { + return ( +
+

+ + Choose a track on Python 101 or Data Analysis to start tracking progress. + +

+
+ ); + } + + return ( +
+
+
+
+ {percent}% +
+
+
+
+

+ Overall progress +

+
+
+
+
+

+ + {weeksDone} / {weeksTotal} weeks + + ), + projects: ( + + {projectsDone} / {projectsTotal}{' '} + projects + + ), + }}> + {'{weeks} completed and {projects} built'} + +

+
+
+
+ ); +} export default function ProgressPage(): ReactNode { const courseComplete = useCourseComplete(); @@ -17,6 +104,11 @@ export default function ProgressPage(): ReactNode { id: 'progressPage.pageDescription', message: 'Track your course progress and badges.', })}> + {/* Personal, localStorage-driven page — thin duplicate content with no + standalone SEO value, so keep it out of search results. */} + + +

My Progress @@ -41,6 +133,7 @@ export default function ProgressPage(): ReactNode { )} + diff --git a/src/utils/storageKeys.ts b/src/utils/storageKeys.ts index 2cf94fe..34a112d 100644 --- a/src/utils/storageKeys.ts +++ b/src/utils/storageKeys.ts @@ -9,6 +9,7 @@ export const STORAGE_KEYS = { weeklyQuiz: 'pda-course:weekly-quiz', badges: 'pda-course:badges', projectProgress: 'pda-course:capstone-progress', + projectFavorites: 'pda-course:favorite-projects', studentName: 'pda-course:student-name', lastVisit: 'pda-course:last-visit', studentId: 'pda-course:student-id', diff --git a/static/llms.txt b/static/llms.txt index 2007976..c1c56d3 100644 --- a/static/llms.txt +++ b/static/llms.txt @@ -2,13 +2,13 @@ > A free, browser-based Python and data analysis course. No installs required until the capstone. Runs entirely in-browser via self-hosted JupyterLite (Pyodide/WebAssembly), works offline once installed as a PWA. Available in English, Arabic, Spanish, and French. -Two 5-week sections, each with a Normal track (guided fundamentals) and a Hard track (project-based, no shortcuts): Python 101 builds a tiny language model from scratch in pure Python; Data Analysis runs a full exploratory data analysis project with pandas. A Capstone project is added every year after the core curriculum, with past years' projects staying available. +Two 5-week sections, each with a Normal track (guided fundamentals) and a Hard track (project-based, no shortcuts): Python 101 builds a tiny language model from scratch in pure Python; Data Analysis runs a full exploratory data analysis project with pandas. A growing library of real-world projects teaches the graduation step: installing Python for real and building something the in-browser playground can't run — new projects are added whenever a trending tool or framework is worth covering. ## Course - [Python 101](https://pyda-course.online/docs/python-101): variables, control flow, data structures, functions, CSV files — Normal track for fundamentals, Hard track builds a tiny language model from scratch in pure Python. - [Pandas & Data Analysis](https://pyda-course.online/docs/data-analysis): loading, cleaning, filtering, grouping real data — Normal track reproduces a Kaggle-style notebook, Hard track runs a full exploratory data analysis project. -- [Capstone Projects](https://pyda-course.online/docs/bonus): install Python locally for real and build something the in-browser playground never could. A new project every year; past years stay available. +- [Real-World Projects](https://pyda-course.online/docs/projects): install Python locally for real and build something the in-browser playground never could — AI agents, MCP servers, RAG apps, web scrapers, games, and more. Browse freely any time; no completion gate. ## About diff --git a/tests/e2e/projects-gallery.spec.ts b/tests/e2e/projects-gallery.spec.ts new file mode 100644 index 0000000..954330f --- /dev/null +++ b/tests/e2e/projects-gallery.spec.ts @@ -0,0 +1,126 @@ +import {test, expect} from '@playwright/test'; +import {skipOnboarding} from './helpers'; + +const TOTAL = 29; + +test.describe('projects gallery', () => { + test.beforeEach(async ({page}) => { + await skipOnboarding(page); + }); + + test('serves only the initial batch server-side (SSR) and loads more on scroll', async ({page, request}) => { + // SSR: the raw HTML must contain exactly the initial batch, not all projects. + // (React SSR omits attribute quotes, hence the optional quotes in the match.) + const raw = await (await request.get('/')).text(); + expect(raw.match(/data-testid=["']?gallery-card/g)?.length).toBe(12); + + await page.goto('/'); + await expect(page.getByTestId('gallery-counter')).toHaveText(`Showing ${12} of ${TOTAL}`); + + const cardCount = () => page.getByTestId('gallery-card').count(); + const atEnd = async () => (await page.getByTestId('gallery-end').count()) > 0; + + // Scroll down a viewport at a time; each batch the sentinel loads pushes + // it further down, so this keeps re-crossing its intersection boundary. + for (let i = 0; i < 20 && !(await atEnd()); i++) { + await page.evaluate(() => window.scrollBy(0, window.innerHeight)); + await page.waitForTimeout(120); + } + + await expect(page.getByTestId('gallery-end')).toBeVisible(); + await expect(page.getByTestId('gallery-counter')).toHaveText( + `Showing ${TOTAL} of ${TOTAL}`, + ); + await expect.poll(async () => cardCount()).toBe(TOTAL); + }); + + test('starring a card persists to localStorage and toggles off', async ({page}) => { + await page.goto('/'); + + const card = page.getByTestId('gallery-card').filter({ + has: page.getByRole('heading', {name: 'Build a Dependency-Freshness Checker'}), + }); + const star = card.getByTestId('favorite-button'); + await expect(star).toHaveAttribute('aria-pressed', 'false'); + + await star.click(); + await expect(star).toHaveAttribute('aria-pressed', 'true'); + + const stored = await page.evaluate(() => + window.localStorage.getItem('pda-course:favorite-projects'), + ); + expect(stored).toContain('2027-dependency-freshness-checker'); + + await star.click(); + await expect(star).toHaveAttribute('aria-pressed', 'false'); + }); + + test('favorites filter shows only starred projects', async ({page}) => { + await page.goto('/'); + + const card = page.getByTestId('gallery-card').filter({ + has: page.getByRole('heading', {name: 'Build a Dependency-Freshness Checker'}), + }); + await card.getByTestId('favorite-button').click(); + + await page.getByRole('button', {name: /Favorites/}).click(); + + await expect(page.getByTestId('gallery-counter')).toHaveText(`Showing ${1} of ${1}`); + await expect(page.getByTestId('gallery-card')).toHaveCount(1); + await expect( + page.getByRole('heading', {name: 'Build a Dependency-Freshness Checker'}), + ).toBeVisible(); + }); + + test('favorites are shared across the homepage and the docs projects page', async ({page}) => { + await page.goto('/'); + + const card = page.getByTestId('gallery-card').filter({ + has: page.getByRole('heading', {name: 'Build a Browser-Automation Agent'}), + }); + await card.getByTestId('favorite-button').click(); + + await page.goto('/docs/projects'); + const favoritesButton = page.getByRole('button', {name: /Favorites/}); + await expect(favoritesButton).toContainText('1'); + await favoritesButton.click(); + await expect(page.getByTestId('gallery-counter')).toHaveText(`Showing ${1} of ${1}`); + await expect( + page.getByRole('heading', {name: 'Build a Browser-Automation Agent'}), + ).toBeVisible(); + }); + + test('every card shows a level badge and a tools row', async ({page}) => { + await page.goto('/'); + + const firstCard = page.getByTestId('gallery-card').first(); + await expect(firstCard.getByTestId('level-badge')).toBeVisible(); + await expect(firstCard.getByTestId('card-tools')).toBeVisible(); + await expect(firstCard.getByTestId('card-tools').getByRole('listitem').first()).toBeVisible(); + await expect(firstCard.locator('svg').first()).toBeAttached(); + }); + + test('level filter narrows to projects of that difficulty', async ({page}) => { + await page.goto('/'); + + await page.getByTestId('level-filter-beginner').click(); + await expect(page.getByTestId('gallery-counter')).toHaveText(`Showing ${12} of ${17}`); + await expect(page.getByTestId('level-badge')).toHaveCount(12); + await expect(page.getByTestId('level-badge').first()).toHaveText('Beginner'); + + // A beginner project is in the first batch and now visible. + await expect( + page.getByRole('heading', {name: 'Build a Wordle Clone'}), + ).toBeVisible(); + + // Toggle off, then try Advanced (a single Hard-track project). + await page.getByTestId('level-filter-beginner').click(); + await page.getByTestId('level-filter-advanced').click(); + await expect(page.getByTestId('gallery-counter')).toHaveText(`Showing ${1} of ${1}`); + await expect(page.getByTestId('level-badge')).toHaveCount(1); + await expect(page.getByTestId('level-badge').first()).toHaveText('Advanced'); + await expect( + page.getByRole('heading', {name: 'Fine-tune a Small Language Model'}), + ).toBeVisible(); + }); +});