|
1 | 1 | import { Suspense, lazy, useEffect, useState } from 'react' |
2 | 2 | import { useTranslation } from 'react-i18next' |
| 3 | +import { LayoutGrid, List } from 'lucide-react' |
3 | 4 | import { EPISODES, LECTURES, MODULES, PAPERS, lectureScope, papersUrl, moduleUrl, noteUrl, watchUrl } from '../../lib/sitemap' |
4 | 5 | import type { ModuleInfo } from '../../lib/sitemap' |
5 | 6 | import { readVisited } from '../ui' |
6 | 7 |
|
7 | 8 | // the explainer thumbnails pull in the animation code and KaTeX: load them after the rest of the page |
8 | 9 | const WatchStrip = lazy(() => import('../watch/WatchStrip')) |
| 10 | +const HomeLab = lazy(() => import('./HomeLab')) |
| 11 | + |
| 12 | +type View = 'map' | 'lab' |
| 13 | +const VIEW_KEY = 'knowcs-home-view' |
| 14 | +const readView = (): View => { |
| 15 | + try { |
| 16 | + return localStorage.getItem(VIEW_KEY) === 'lab' ? 'lab' : 'map' |
| 17 | + } catch { |
| 18 | + return 'map' |
| 19 | + } |
| 20 | +} |
| 21 | +const GRID = { backgroundImage: 'linear-gradient(#e7ebf1 1px, transparent 1px), linear-gradient(90deg, #e7ebf1 1px, transparent 1px)', backgroundSize: '32px 32px' } |
| 22 | + |
| 23 | +/** Course map (lectures in order) or lab grid (every lab by area); the choice is remembered. */ |
| 24 | +function ViewSwitch({ view, onChange }: { view: View; onChange: (v: View) => void }) { |
| 25 | + const { t } = useTranslation() |
| 26 | + return ( |
| 27 | + <div className="mx-auto flex max-w-[1280px] justify-end px-4 pt-4 sm:px-8"> |
| 28 | + <div className="inline-flex gap-1 rounded-xl border border-slate-200 bg-white p-1 text-sm font-bold shadow-sm" role="group" aria-label={t('site.home.view_label')}> |
| 29 | + {(['map', 'lab'] as const).map((v) => ( |
| 30 | + <button |
| 31 | + key={v} |
| 32 | + type="button" |
| 33 | + onClick={() => onChange(v)} |
| 34 | + aria-pressed={view === v} |
| 35 | + className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 transition ${view === v ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'}`} |
| 36 | + > |
| 37 | + {v === 'map' ? <List size={15} aria-hidden /> : <LayoutGrid size={15} aria-hidden />} |
| 38 | + {t(`site.home.view_${v}`)} |
| 39 | + </button> |
| 40 | + ))} |
| 41 | + </div> |
| 42 | + </div> |
| 43 | + ) |
| 44 | +} |
9 | 45 |
|
10 | 46 | type Scope = 'all' | 'mid' | 'final' |
11 | 47 |
|
@@ -43,15 +79,36 @@ export default function HomePage() { |
43 | 79 | const [scope, setScope] = useState<Scope>('all') |
44 | 80 | const [q, setQ] = useState('') |
45 | 81 | const [visited] = useState(readVisited) |
| 82 | + const [view, setViewState] = useState<View>(readView) |
| 83 | + const setView = (v: View) => { |
| 84 | + setViewState(v) |
| 85 | + try { |
| 86 | + localStorage.setItem(VIEW_KEY, v) |
| 87 | + } catch { |
| 88 | + // private mode: the choice just isn't remembered |
| 89 | + } |
| 90 | + } |
46 | 91 | const query = q.trim().toLowerCase() |
47 | 92 | useEffect(() => { |
48 | 93 | document.title = `KnowCS · ${t('site.home.tab_title')}` |
49 | 94 | }, [t]) |
50 | 95 | const matches = (m: ModuleInfo) => !query || `${t(`site.modules.${m.id}.title`)} ${t(`site.modules.${m.id}.blurb`)}`.toLowerCase().includes(query) |
51 | 96 |
|
| 97 | + if (view === 'lab') { |
| 98 | + return ( |
| 99 | + <main className="min-h-screen pb-10" style={GRID}> |
| 100 | + <ViewSwitch view={view} onChange={setView} /> |
| 101 | + <Suspense fallback={<div className="min-h-screen" />}> |
| 102 | + <HomeLab /> |
| 103 | + </Suspense> |
| 104 | + </main> |
| 105 | + ) |
| 106 | + } |
| 107 | + |
52 | 108 | return ( |
53 | 109 | <main className="pb-10"> |
54 | | - <section className="mx-auto max-w-[1280px] px-4 pb-6 pt-8 sm:px-8"> |
| 110 | + <ViewSwitch view={view} onChange={setView} /> |
| 111 | + <section className="mx-auto max-w-[1280px] px-4 pb-6 pt-2 sm:px-8"> |
55 | 112 | <div className="grid items-end gap-6 lg:grid-cols-[minmax(0,1fr)_420px]"> |
56 | 113 | <div> |
57 | 114 | <p className="text-xs font-extrabold uppercase tracking-[0.14em] text-blue-600">{t('site.home.eyebrow')}</p> |
|
0 commit comments