From ce222c4960d927c89a61d2937749bf0d354bb7bf Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Thu, 30 Jul 2026 13:38:31 +0200 Subject: [PATCH] feat(catalog): port the netcup landing page design The netcup test host served an older hand-built static page that course-v2 never had: dark hero, stat row, filter chips and per-module cards. Management have already seen that design, so the plain catalog read as a downgrade. Structure follows the reference: one section per track and level, with module cards inside it, rather than the level rows course-v2 had. Data still comes from the manifest, and a new getWrittenModules() checks which modules have content on disk, so writing content/php/intermediate/1.md updates the page by itself. Fixes the affordance regression in the same pass. Only the level rows used to be clickable - title, badge and tagline were inert and there was no start button. Each card is now one anchor, so keyboard and middle-click work, and the call to action inside it is a span rather than a nested link. Planned modules are not linked at all, since their pages would 404. They render inert with a Soon badge and a hover plate. Completion is visible on the card too: Done and In progress badges, each spelling the state out rather than relying on colour alone. The header button follows progress - start the first module, continue where you left off, or go green when the level is finished. Design tokens live in an @theme block so they generate utilities, keeping the port in the app's existing idiom instead of a parallel stylesheet. Added --color-nc-green-dark because white on --color-nc-green is 3.15:1, which fails WCAG AA for anything short of large bold text. Not ported: the standalone and refresher sections, which have no routes yet, and the hero's dot field, dropped deliberately. Signed-off-by: Anna Larch --- app/globals.css | 34 +- app/layout.tsx | 12 +- app/page.tsx | 5 +- components/CatalogClient.tsx | 632 +++++++++++++++++++++++++++--- components/LevelCatalogClient.tsx | 41 +- components/TopBar.tsx | 73 ++-- lib/content.ts | 30 +- 7 files changed, 714 insertions(+), 113 deletions(-) diff --git a/app/globals.css b/app/globals.css index d3c2913..0c84e18 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,10 +1,34 @@ @import "tailwindcss"; -@layer base { - :root { - --nc-blue: #0082c9; - --nc-blue-dark: #006aa3; - } +/* + * Design tokens for the "Build apps for 30 million users" front-end. + * + * Declared in @theme rather than as plain custom properties so each one also + * generates the matching utilities (bg-navy, text-muted, border-rule). The + * catalog is then styled the same way as the rest of the app โ€” utility classes + * in the components โ€” instead of needing a parallel stylesheet. + */ +@theme { + --color-navy: #003356; + --color-navy-mid: #00477a; + --color-nc-blue: #0082c9; + --color-nc-blue-dark: #006aa3; + --color-nc-blue-light: #e8f4fd; + --color-nc-green: #46a35e; + /* + * White on --color-nc-green is only 3.15:1, which fails WCAG AA for anything + * short of large bold text. Use this for green that carries white text, and + * for green text on a light background. + */ + --color-nc-green-dark: #2d7a45; + --color-nc-green-light: #e8f5e9; + --color-ink: #1a1f2e; + --color-muted: #5a6a7e; + --color-rule: #dde3ea; + --color-off-white: #f8fafb; + + /* Mono is load-bearing in this design: eyebrows, stat numbers, card meta. */ + --font-mono: var(--font-jetbrains-mono), ui-monospace, SFMono-Regular, monospace; } /* Prose styles for markdown content */ diff --git a/app/layout.tsx b/app/layout.tsx index 2696461..8d10d2b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,17 @@ import type { Metadata } from 'next' -import { Inter } from 'next/font/google' +import { Inter, JetBrains_Mono } from 'next/font/google' import './globals.css' const inter = Inter({ subsets: ['latin'] }) +// Exposed as a variable rather than a class, because --font-mono in globals.css +// points at it and the mono utilities are used all over the catalog. +const jetBrainsMono = JetBrains_Mono({ + subsets: ['latin'], + weight: ['400', '600'], + variable: '--font-jetbrains-mono', +}) + export const metadata: Metadata = { title: 'Nextcloud Developer Course (Beta)', description: 'Learn to build Nextcloud apps โ€” PHP and ExApp tracks. Beta: only the beginner tracks are written so far.', @@ -12,7 +20,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/app/page.tsx b/app/page.tsx index 2acd352..a2d1a36 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,10 @@ import { getManifest } from '@/lib/manifest' +import { getWrittenModules } from '@/lib/content' import CatalogClient from '@/components/CatalogClient' export default function HomePage() { const manifest = getManifest() - return + // Availability is a filesystem question, so it is answered here on the server + // and handed to the client component alongside the manifest. + return } diff --git a/components/CatalogClient.tsx b/components/CatalogClient.tsx index 8f1cbbc..be74b5b 100644 --- a/components/CatalogClient.tsx +++ b/components/CatalogClient.tsx @@ -1,87 +1,611 @@ 'use client' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import TopBar from './TopBar' import type { CourseManifest } from '@/lib/types' -import { getLevelProgress } from '@/lib/progress' +import { REPO_URL } from '@/lib/feedback' +import { getLevelProgress, getModuleProgress } from '@/lib/progress' -const TRACK_COLORS: Record = { - php: 'bg-purple-100 text-purple-800 border-purple-200', - exapp: 'bg-green-100 text-green-800 border-green-200', +/** + * Per-track chrome: glyph, short label and card tint. + * + * Presentation only โ€” every string a reader actually reads (titles, taglines, + * durations, module counts) comes from the manifest. Unknown tracks fall back to + * a neutral style, so adding a track to the manifest cannot break this page. + */ +const TRACK_STYLE: Record = { + php: { + label: 'PHP', + glyph: '๐Ÿ˜', + badge: 'bg-navy text-[#7ab8e8]', + icon: 'bg-nc-blue-light', + }, + exapp: { + label: 'ExApp Python', + glyph: '๐Ÿ', + badge: 'bg-nc-green-light text-nc-green-dark', + icon: 'bg-nc-green-light', + }, +} + +const FALLBACK_TRACK_STYLE = { + label: 'Track', + glyph: 'โ—†', + badge: 'bg-rule text-ink', + icon: 'bg-rule', } const LEVEL_ORDER = ['beginner', 'intermediate', 'advanced'] -export default function CatalogClient({ manifest }: { manifest: CourseManifest }) { +const LEVEL_BADGE: Record = { + beginner: 'bg-[#dcfce7] text-[#166534]', + intermediate: 'bg-[#fef9c3] text-[#854d0e]', + advanced: 'bg-[#fee2e2] text-[#991b1b]', +} + +const trackStyle = (trackId: string) => TRACK_STYLE[trackId] ?? FALLBACK_TRACK_STYLE + +const CHIP_BASE = + 'shrink-0 whitespace-nowrap rounded-full border px-3.5 py-1 text-xs font-medium transition-colors cursor-pointer' + +/** + * Time buckets for the duration filter, matching the reference design. + * `max: null` means "no upper bound". + */ +const TIME_BUCKETS: { id: string; label: string; min: number; max: number | null }[] = [ + { id: 'short', label: 'โ‰ค30 min', min: 0, max: 30 }, + { id: 'medium', label: '31โ€“60 min', min: 31, max: 60 }, + { id: 'long', label: '>60 min', min: 61, max: null }, +] + +/** + * One module, flattened out of the manifest with its track and level alongside. + * + * Filtering, counting and the "is this readable yet" test all work on this one + * list, so a module never appears in a section its own data disagrees with. + */ +interface CatalogModule { + key: string + trackId: string + levelId: string + index: number + title: string + description: string + minutes: number + /** Has content on disk. False means planned, and the card is inert. */ + available: boolean +} + +/** A track/level pair, as one section of the page. */ +interface CatalogSection { + key: string + trackId: string + levelId: string + title: string + estimatedHours: string + modules: CatalogModule[] + /** + * First readable module, ignoring the filters. The header call to action + * falls back to this before progress has been read from localStorage, so + * server-rendered markup still points somewhere sensible. + */ + firstWrittenIndex: number | null +} + +/** + * What a reader has done with a module, once progress has been read. + * + * Absent means untouched. Every state carries a visible word on the card as + * well as a colour, so the cue does not depend on colour perception. + */ +type ModuleState = 'done' | 'started' + +/** State of a level's header call to action, once progress is known. */ +interface SectionCta { + href: string + label: string + /** Whole level finished - the button goes green and stops saying "continue". */ + done: boolean +} + +export default function CatalogClient({ + manifest, + writtenModules, +}: { + manifest: CourseManifest + writtenModules: Record +}) { const [progress, setProgress] = useState>({}) + const [ctas, setCtas] = useState>({}) + const [moduleStates, setModuleStates] = useState>({}) + const [trackFilter, setTrackFilter] = useState('all') + const [levelFilter, setLevelFilter] = useState('all') + const [timeFilter, setTimeFilter] = useState('all') useEffect(() => { const p: Record = {} + const c: Record = {} + const m: Record = {} + for (const [trackId, track] of Object.entries(manifest.tracks)) { for (const [levelId, level] of Object.entries(track.levels)) { const key = `${trackId}/${levelId}` p[key] = getLevelProgress(trackId, levelId, level.modules.map(m => ({ id: m.id, index: m.index }))) + + // Only readable modules can be a destination; a planned one would 404. + const written = writtenModules[key] ?? [] + const readable = level.modules.filter(m => written.includes(m.index)) + if (readable.length === 0) continue + + const states = readable.map(m => ({ + index: m.index, + progress: getModuleProgress(trackId, levelId, m.id, m.index), + })) + const touched = (s: (typeof states)[number]) => + s.progress.completed || s.progress.lastSection !== null || s.progress.completedSections.length > 0 + + for (const s of states) { + if (s.progress.completed) m[`${key}/${s.index}`] = 'done' + else if (touched(s)) m[`${key}/${s.index}`] = 'started' + } + + if (states.every(s => s.progress.completed)) { + // Finished: the level page is the useful destination now, since the + // reader is revisiting rather than working through. + c[key] = { href: `/${trackId}/${levelId}`, label: 'Completed', done: true } + continue + } + + /* + * "Where I left off": the furthest module that has been opened but not + * finished. Falling back to the first unfinished module covers both the + * untouched level and the reader who completed 1-3 and never opened 4. + */ + const inFlight = states.filter(s => !s.progress.completed && touched(s)) + const target = inFlight.at(-1) ?? states.find(s => !s.progress.completed) + if (!target) continue + + c[key] = { + href: `/${trackId}/${levelId}/${target.index}`, + label: states.some(touched) ? 'Continue โ†’' : 'Start track โ†’', + done: false, + } } } + setProgress(p) - }, [manifest]) + setCtas(c) + setModuleStates(m) + }, [manifest, writtenModules]) + + const sections = useMemo(() => { + const flat: CatalogSection[] = [] + for (const [trackId, track] of Object.entries(manifest.tracks)) { + for (const levelId of LEVEL_ORDER.filter(l => track.levels[l])) { + const level = track.levels[levelId] + const written = writtenModules[`${trackId}/${levelId}`] ?? [] + flat.push({ + key: `${trackId}/${levelId}`, + trackId, + levelId, + // "PHP App Track โ€” Beginner", as in the reference design. + title: `${track.title} โ€” ${level.title}`, + estimatedHours: level.estimated_hours, + firstWrittenIndex: level.modules.find(m => written.includes(m.index))?.index ?? null, + modules: level.modules.map(m => ({ + key: `${trackId}/${levelId}/${m.index}`, + trackId, + levelId, + index: m.index, + title: m.title, + description: m.description, + minutes: m.estimated_minutes, + available: written.includes(m.index), + })), + }) + } + } + return flat + }, [manifest, writtenModules]) + + /** Hero figures, all derived from the manifest plus what is written. */ + const stats = useMemo(() => { + let writtenMinutes = 0 + let writtenCount = 0 + let plannedCount = 0 + + for (const [trackId, track] of Object.entries(manifest.tracks)) { + for (const [levelId, level] of Object.entries(track.levels)) { + const written = writtenModules[`${trackId}/${levelId}`] ?? [] + plannedCount += level.modules.length + writtenCount += written.length + writtenMinutes += level.modules + .filter(m => written.includes(m.index)) + .reduce((sum, m) => sum + m.estimated_minutes, 0) + } + } + + return { + tracks: Object.keys(manifest.tracks).length, + written: writtenCount, + hours: Math.round(writtenMinutes / 60), + planned: plannedCount, + } + }, [manifest, writtenModules]) + + const levelIds = useMemo( + () => LEVEL_ORDER.filter(l => sections.some(s => s.levelId === l)), + [sections], + ) + + const matches = (mod: CatalogModule) => { + if (trackFilter !== 'all' && trackFilter !== mod.trackId) return false + if (levelFilter !== 'all' && levelFilter !== mod.levelId) return false + if (timeFilter === 'all') return true + const bucket = TIME_BUCKETS.find(b => b.id === timeFilter) + if (!bucket) return true + return mod.minutes >= bucket.min && (bucket.max === null || mod.minutes <= bucket.max) + } + + /* + * Filtering happens per module, and a section drops out when nothing in it + * survives - so "โ‰ค30 min" cannot leave an empty "PHP App Track โ€” Advanced" + * heading behind with no cards under it. + */ + const visibleSections = sections + .map(section => ({ ...section, modules: section.modules.filter(matches) })) + .filter(section => section.modules.length > 0) + + const visibleCount = visibleSections.reduce((sum, s) => sum + s.modules.length, 0) + const totalCount = sections.reduce((sum, s) => sum + s.modules.length, 0) return ( -
+
-
-
-

{manifest.course.title}

-

Two tracks, three levels. Pick your starting point below.

+ + {/* Hero */} +
+ {/* Light bloom on the right, as in the reference design. */} +
+ + {/* Filter bar */} +
+
+ + Filter + + + setTrackFilter('all')}> + All tracks + + {Object.keys(manifest.tracks).map(trackId => ( + setTrackFilter(trackId)} + > + {trackStyle(trackId).glyph} {trackStyle(trackId).label} + + ))} -
- {Object.entries(manifest.tracks).map(([trackId, track]) => ( -
-
- - {trackId === 'php' ? 'PHP' : 'ExApp / Python'} +
+
+ +
+ {visibleSections.map(section => { + const style = trackStyle(section.trackId) + const written = section.modules.filter(m => m.available).length + const percent = progress[section.key] ?? 0 + const cta = ctas[section.key] + // Pre-hydration, and for a level whose modules are all filtered out. + const fallbackHref = section.firstWrittenIndex !== null + ? `/${section.trackId}/${section.levelId}/${section.firstWrittenIndex}` + : `/${section.trackId}/${section.levelId}` + + return ( +
+
+ + {style.glyph} {style.label} + +

{section.title}

+ + {/* + * The reference design says a bare "coming soon" for a level + * with nothing written, rather than advertising a module count + * and duration nobody can read yet. + */} + {written === 0 + ? 'coming soon' + : `${section.modules.length} modules ยท ~${section.estimatedHours}h`} -

{track.title}

-

{track.tagline}

+ {/* + * Progress as a coloured pill rather than grey run-on text: + * it is the one number a returning reader looks for, and it + * went unnoticed sitting inside the meta line. + */} + {percent > 0 && ( + + {percent}% complete + + )} + {written > 0 && ( + + {cta?.done && } + {cta?.label ?? 'Start track โ†’'} + + )}
-
- {LEVEL_ORDER.filter(l => track.levels[l]).map(levelId => { - const level = track.levels[levelId] - const pct = progress[`${trackId}/${levelId}`] ?? 0 - return ( - -
-
{level.title}
-
{level.tagline}
-
{level.modules.length} modules ยท {level.estimated_hours}h
-
-
- {pct > 0 && ( -
-
{pct}%
-
-
-
-
- )} - - - -
- - ) - })} + +
+ {section.modules.map(mod => ( + + ))}
-
- ))} -
+
+ ) + })} + + {visibleSections.length === 0 && ( +
+ +

No modules match the current filters.

+
+ )}
+ +
) } + +function FilterChip({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +const CARD_SHELL = + 'group relative flex flex-col gap-2.5 overflow-hidden rounded-lg border p-5 transition-all' + +/** + * One module, as a card - the granularity the reference design uses on the + * landing page. + * + * A written module is a single anchor: icon, id, title, tagline and footer are + * all inside it, so a click anywhere on the card navigates, and keyboard and + * middle-click behaviour come for free. + * + * A module that exists only in the manifest is not a link at all - its page + * would 404 - so it renders as an inert div. Nothing to click, nothing in the + * tab order. + */ +function ModuleCard({ mod, state }: { mod: CatalogModule; state?: ModuleState }) { + const style = trackStyle(mod.trackId) + const done = state === 'done' + + const body = ( + <> + {mod.available ? ( +