From 0b2daa8c81346bfbfa55b8c64573adfdbb1c73eb Mon Sep 17 00:00:00 2001 From: Abderrahim Adrabi <184391033+abderrahim-lectures@users.noreply.github.com> Date: Sun, 2 Aug 2026 03:50:40 +0100 Subject: [PATCH 1/2] ui: polish homepage, projects list, and progress page Give the whole site a more polished, cohesive feel without touching the learning content or the translated strings: - Homepage hero now shows the course's real highlights (10 weeks, project count, zero installs) and uses a gradient that matches the site's violet/amber palette in both light and dark mode. - The real-world projects grid (homepage and the /docs/projects page) can now be filtered by topic with tag chips, and completed projects show a 'Completed' mark and a success-tinted border. - The My Progress page gets an overall progress summary card that combines week completion and project completion into one number. - Cards lift slightly on hover, and keyboard focus is now visible site-wide. English only; translated strings land in a follow-up i18n PR. Co-authored-by: deepseek-v4-flash-free --- src/components/ProjectChooser/index.tsx | 97 +++++++++----- .../ProjectChooser/styles.module.css | 40 ++++++ src/css/custom.css | 9 ++ src/pages/index.module.css | 121 ++++++++++++++++++ src/pages/index.tsx | 107 +++++++++++++++- src/pages/progress.module.css | 44 +++++++ src/pages/progress.tsx | 69 ++++++++++ 7 files changed, 449 insertions(+), 38 deletions(-) create mode 100644 src/pages/progress.module.css diff --git a/src/components/ProjectChooser/index.tsx b/src/components/ProjectChooser/index.tsx index 246f557..b783255 100644 --- a/src/components/ProjectChooser/index.tsx +++ b/src/components/ProjectChooser/index.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, {useMemo, useState} from 'react'; import Link from '@docusaurus/Link'; import Translate from '@docusaurus/Translate'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import clsx from 'clsx'; import {useLocalStorage} from '@site/src/hooks/useLocalStorage'; import {STORAGE_KEYS} from '@site/src/utils/storageKeys'; import {formatProjectDate} from '@site/src/data/projects'; @@ -44,39 +45,71 @@ export default function ProjectChooser({projects}: Props): React.JSX.Element { return a.id < b.id ? -1 : 1; }); + 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 filtered = activeTag === null ? sorted : sorted.filter((p) => p.tags.includes(activeTag)); + return ( -
- {sorted.map((project) => { - const completed = progress[project.id] ?? false; - return ( - -

+ <> +
+ + {tags.map((tag) => ( + + ))} +
+
+ {filtered.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 +

)} - {project.title} -

-

{formatProjectDate(project.date, currentLocale)}

-

{project.summary}

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

- Completed -

- )} - - ); - })} -
+ + ); + })} + + ); } diff --git a/src/components/ProjectChooser/styles.module.css b/src/components/ProjectChooser/styles.module.css index 0645ce0..af7127a 100644 --- a/src/components/ProjectChooser/styles.module.css +++ b/src/components/ProjectChooser/styles.module.css @@ -5,6 +5,40 @@ margin: 1.5rem 0; } +.filters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 1.5rem 0 0.5rem; +} + +.filterChip { + font-size: 0.8rem; + font-weight: 600; + padding: 0.35rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--ifm-color-emphasis-300); + background: transparent; + color: var(--ifm-font-color-base); + cursor: pointer; + min-height: 0; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.filterChip:hover { + border-color: var(--pda-accent); + color: var(--pda-accent); +} + +.filterChipActive { + background: var(--pda-accent-soft); + border-color: var(--pda-accent); + color: var(--pda-accent); +} + .card { border: 1px solid var(--ifm-color-emphasis-200); border-radius: var(--pda-radius-lg); @@ -14,12 +48,18 @@ flex-direction: column; color: var(--ifm-font-color-base); text-decoration: none; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + border-color 0.15s ease; } .card:hover { border-color: var(--pda-accent); text-decoration: none; color: var(--ifm-font-color-base); + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); } .card h3 { diff --git a/src/css/custom.css b/src/css/custom.css index 5d00fbc..60291cf 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -59,3 +59,12 @@ body[data-ui-mode='classical'] .gamified-flourish { display: none; } + +/* Visible keyboard focus for every interactive element, so the site stays + usable without a mouse. Infima's default outline is nearly invisible; this + gives it the accent color and a touch more breathing room. */ +:focus-visible { + outline: 3px solid var(--pda-accent); + outline-offset: 2px; + border-radius: 4px; +} diff --git a/src/pages/index.module.css b/src/pages/index.module.css index 29261c0..1d985e5 100644 --- a/src/pages/index.module.css +++ b/src/pages/index.module.css @@ -8,6 +8,34 @@ text-align: center; position: relative; overflow: hidden; + background: linear-gradient( + 135deg, + var(--ifm-color-primary-darkest) 0%, + var(--ifm-color-primary) 55%, + var(--pda-accent) 140% + ); + color: #fff; +} + +.heroBanner :global(.button--secondary) { + background: #fff; + color: var(--ifm-color-primary-darker); + border-color: #fff; +} + +.heroBanner :global(.button--secondary):hover { + background: var(--pda-accent-soft); + color: var(--ifm-color-primary-darker); + border-color: #fff; +} + +[data-theme='dark'] .heroBanner { + background: linear-gradient( + 135deg, + #171428 0%, + var(--ifm-color-primary-darkest) 45%, + var(--pda-accent) 150% + ); } @media screen and (max-width: 996px) { @@ -16,6 +44,43 @@ } } +.heroTitle { + font-size: clamp(2rem, 5vw, 3rem); + margin-bottom: 0.5rem; +} + +.heroSubtitle { + font-size: 1.1rem; + margin-bottom: 2rem; + opacity: 0.9; +} + +.heroStats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem 2.5rem; + margin-bottom: 2rem; +} + +.heroStat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + +.heroStatValue { + font-size: 2rem; + font-weight: 800; + line-height: 1; +} + +.heroStatLabel { + font-size: 0.85rem; + opacity: 0.8; +} + .buttons { display: flex; flex-wrap: wrap; @@ -50,9 +115,43 @@ } .projectsIntro { + margin-bottom: 1.25rem; +} + +.projectFilters { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; margin-bottom: 1.5rem; } +.projectFilterChip { + font-size: 0.8rem; + font-weight: 600; + padding: 0.35rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--ifm-color-emphasis-300); + background: transparent; + color: var(--ifm-font-color-base); + cursor: pointer; + min-height: 0; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.projectFilterChip:hover { + border-color: var(--pda-accent); + color: var(--pda-accent); +} + +.projectFilterChipActive { + background: var(--pda-accent-soft); + border-color: var(--pda-accent); + color: var(--pda-accent); +} + .projectGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); @@ -68,12 +167,19 @@ flex-direction: column; color: var(--ifm-font-color-base); text-decoration: none; + position: relative; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + border-color 0.15s ease; } .projectCard:hover { border-color: var(--pda-accent); text-decoration: none; color: var(--ifm-font-color-base); + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); } .projectCard h3 { @@ -101,3 +207,18 @@ background: var(--pda-accent-soft); color: var(--pda-accent); } + +.projectCardCompleted { + border-color: var(--ifm-color-success); +} + +.projectCompleted { + position: absolute; + top: 1rem; + right: 1rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ifm-color-success); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index c1ed17a..dc135a9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,4 +1,4 @@ -import type {ReactNode} from 'react'; +import {createContext, useContext, useState, type ReactNode} from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; import Translate, {translate} from '@docusaurus/Translate'; @@ -6,7 +6,10 @@ import Layout from '@theme/Layout'; import Heading from '@theme/Heading'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import InstallPwaButton from '@site/src/components/InstallPwaButton'; +import {useLocalStorage} from '@site/src/hooks/useLocalStorage'; +import {STORAGE_KEYS} from '@site/src/utils/storageKeys'; import {PROJECTS, formatProjectDate} from '@site/src/data/projects'; +import type {ProjectProgressMap} from '@site/src/types/progress'; import styles from './index.module.css'; @@ -21,18 +24,44 @@ function projectMeta(id: string) { function HomepageHeader() { return ( -
+
- + Python & Data Analysis Course -

+

Learn Python and data analysis in your browser — no installs needed

+
+
+ 10 + + + weeks + + +
+
+ {PROJECTS.length}+ + + + real-world projects + + +
+
+ 0 + + + installs to start + + +
+
@@ -143,6 +172,18 @@ interface HomepageProjectCardProps { summary: ReactNode; } +/** + * Lets each statically-defined project card opt out of rendering when a tag + * filter chip is active — cards stay as literal JSX (required for + * Docusaurus's static i18n extraction), and the filter chips live in the + * parent section. null means "show all". + */ +const ProjectTagContext = createContext(null); + +function useActiveTag(): string | null { + return useContext(ProjectTagContext); +} + /** * date/url/tags come from the shared PROJECTS source of truth; title/summary * are passed in as already-built elements from the call site @@ -155,9 +196,16 @@ function HomepageProjectCard({id, title, summary}: HomepageProjectCardProps) { const { i18n: {currentLocale}, } = useDocusaurusContext(); + const [progress] = useLocalStorage(STORAGE_KEYS.projectProgress, {}); + const completed = progress[id] ?? false; + const activeTag = useActiveTag(); + const matchesFilter = activeTag === null || meta.tags.includes(activeTag); + if (!matchesFilter) { + return null; + } return ( - +

{title}

{formatProjectDate(meta.date, currentLocale)}

{summary}

@@ -168,11 +216,34 @@ function HomepageProjectCard({id, title, summary}: HomepageProjectCardProps) { ))}
+ {completed && ( + + + Completed + + + )} ); } +/** All distinct project tags, ordered by first appearance — drives the filter chips. */ +function allProjectTags(): string[] { + const seen = new Set(); + for (const p of PROJECTS) { + for (const tag of p.tags) { + if (!seen.has(tag)) { + seen.add(tag); + } + } + } + return [...seen]; +} + function RealWorldProjects() { + const [activeTag, setActiveTag] = useState(null); + const tags = allProjectTags(); + return (
@@ -189,7 +260,30 @@ function RealWorldProjects() { browse any time, no need to finish the course first.

-
+
+ + {tags.map((tag) => ( + + ))} +
+ +
+
); diff --git a/src/pages/progress.module.css b/src/pages/progress.module.css new file mode 100644 index 0000000..dc756c0 --- /dev/null +++ b/src/pages/progress.module.css @@ -0,0 +1,44 @@ +.overall { + padding: 1.5rem; + margin-bottom: 2rem; + border-radius: var(--pda-radius-lg); + background: var(--pda-surface); + border: 1px solid var(--ifm-color-emphasis-200); +} + +.overallHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.overallHeader h2 { + margin: 0; +} + +.overallPercent { + font-size: 1.5rem; + font-weight: 800; + color: var(--pda-accent); +} + +.barTrack { + height: 0.75rem; + border-radius: 999px; + background: var(--ifm-color-emphasis-200); + overflow: hidden; + margin: 1rem 0 0.75rem; +} + +.barFill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--ifm-color-primary), var(--pda-accent)); + transition: width 0.3s ease; +} + +.overallDetail { + margin: 0; + color: var(--ifm-color-emphasis-700); +} diff --git a/src/pages/progress.tsx b/src/pages/progress.tsx index f632338..1bfbe84 100644 --- a/src/pages/progress.tsx +++ b/src/pages/progress.tsx @@ -5,7 +5,75 @@ 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 ( +
+
+

+ Overall progress +

+ {percent}% +
+
+
+
+

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

+
+ ); +} export default function ProgressPage(): ReactNode { const courseComplete = useCourseComplete(); @@ -41,6 +109,7 @@ export default function ProgressPage(): ReactNode {
)} + From f2bc8ca141965bf45b6b2a96b87efe0f74357dae Mon Sep 17 00:00:00 2001 From: Abderrahim Adrabi <184391033+abderrahim-lectures@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:30:51 +0100 Subject: [PATCH 2/2] ui: add hero visual, project card art, level badges, and level filter --- docs/projects/index.mdx | 3 + docusaurus.config.ts | 88 +- i18n/ar/code.json | 91 ++ .../current/projects/index.mdx | 3 + i18n/es/code.json | 91 ++ .../current/projects/index.mdx | 3 + i18n/fr/code.json | 91 ++ .../current/projects/index.mdx | 3 + src/components/ProjectChooser/index.tsx | 116 +-- .../ProjectChooser/styles.module.css | 99 --- src/components/ProjectGallery/index.tsx | 295 +++++++ .../ProjectGallery/styles.module.css | 389 +++++++++ src/components/ProjectsListingJsonLd.tsx | 47 + src/css/custom.css | 22 + src/data/homepageProjects.tsx | 527 +++++++++++ src/data/projectArt.tsx | 142 +++ src/data/projects.ts | 82 ++ src/hooks/useFavorites.ts | 24 + src/pages/index.module.css | 489 ++++++++--- src/pages/index.tsx | 821 ++++-------------- src/pages/progress.module.css | 76 +- src/pages/progress.tsx | 82 +- src/utils/storageKeys.ts | 1 + static/llms.txt | 4 +- tests/e2e/projects-gallery.spec.ts | 126 +++ 25 files changed, 2665 insertions(+), 1050 deletions(-) delete mode 100644 src/components/ProjectChooser/styles.module.css create mode 100644 src/components/ProjectGallery/index.tsx create mode 100644 src/components/ProjectGallery/styles.module.css create mode 100644 src/components/ProjectsListingJsonLd.tsx create mode 100644 src/data/homepageProjects.tsx create mode 100644 src/data/projectArt.tsx create mode 100644 src/hooks/useFavorites.ts create mode 100644 tests/e2e/projects-gallery.spec.ts 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 b783255..49c8b54 100644 --- a/src/components/ProjectChooser/index.tsx +++ b/src/components/ProjectChooser/index.tsx @@ -1,115 +1,17 @@ -import React, {useMemo, useState} from 'react'; -import Link from '@docusaurus/Link'; -import Translate from '@docusaurus/Translate'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import clsx from 'clsx'; -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; - }); - - 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 filtered = activeTag === null ? sorted : sorted.filter((p) => p.tags.includes(activeTag)); - - return ( - <> -
- - {tags.map((tag) => ( - - ))} -
-
- {filtered.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 af7127a..0000000 --- a/src/components/ProjectChooser/styles.module.css +++ /dev/null @@ -1,99 +0,0 @@ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 1rem; - margin: 1.5rem 0; -} - -.filters { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - margin: 1.5rem 0 0.5rem; -} - -.filterChip { - font-size: 0.8rem; - font-weight: 600; - padding: 0.35rem 0.85rem; - border-radius: 999px; - border: 1px solid var(--ifm-color-emphasis-300); - background: transparent; - color: var(--ifm-font-color-base); - cursor: pointer; - min-height: 0; - transition: - background 0.15s ease, - border-color 0.15s ease, - color 0.15s ease; -} - -.filterChip:hover { - border-color: var(--pda-accent); - color: var(--pda-accent); -} - -.filterChipActive { - background: var(--pda-accent-soft); - border-color: var(--pda-accent); - color: var(--pda-accent); -} - -.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; - transition: - transform 0.15s ease, - box-shadow 0.15s ease, - border-color 0.15s ease; -} - -.card:hover { - border-color: var(--pda-accent); - text-decoration: none; - color: var(--ifm-font-color-base); - transform: translateY(-3px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); -} - -.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 ? ( +