|
| 1 | +// lessonPage.jsx - /learn/<slug> 레슨 상세. 클라이언트 렌더(직접 URL은 404.html SPA 폴백). |
| 2 | +// static/lessons/<slug>.json(생성물)을 fetch해 섹션(설명 + 실행 셀)을 렌더. 셀은 상단 공유 실행기로 로드. |
| 3 | +import { useEffect, useRef, useState } from "react"; |
| 4 | +import { ArrowLeft, Play } from "lucide-react"; |
| 5 | +import { Heading } from "@astryxdesign/core/Heading"; |
| 6 | +import { Text } from "@astryxdesign/core/Text"; |
| 7 | +import { Button } from "@astryxdesign/core/Button"; |
| 8 | +import { Card } from "@astryxdesign/core/Card"; |
| 9 | +import { brand } from "../lib/brand.js"; |
| 10 | +import { PythonRunner } from "../components/pythonRunner.jsx"; |
| 11 | + |
| 12 | +const appPath = (path = "/") => brand.appPath(path); |
| 13 | + |
| 14 | +export function LessonPage({ slug }) { |
| 15 | + const [lesson, setLesson] = useState(null); |
| 16 | + const [error, setError] = useState(""); |
| 17 | + const [loaded, setLoaded] = useState(null); |
| 18 | + const nonceRef = useRef(0); |
| 19 | + |
| 20 | + useEffect(() => { |
| 21 | + let alive = true; |
| 22 | + setLesson(null); |
| 23 | + setError(""); |
| 24 | + fetch(appPath(`/lessons/${slug}.json`)) |
| 25 | + .then((r) => { |
| 26 | + if (!r.ok) throw new Error("레슨을 찾을 수 없습니다"); |
| 27 | + return r.json(); |
| 28 | + }) |
| 29 | + .then((d) => { if (alive) setLesson(d); }) |
| 30 | + .catch((e) => { if (alive) setError(String((e && e.message) || e)); }); |
| 31 | + return () => { alive = false; }; |
| 32 | + }, [slug]); |
| 33 | + |
| 34 | + function runCell(code) { |
| 35 | + if (!code) return; |
| 36 | + setLoaded({ code, nonce: (nonceRef.current += 1) }); |
| 37 | + if (typeof document !== "undefined") { |
| 38 | + const el = document.getElementById("lesson-runner"); |
| 39 | + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + if (error) { |
| 44 | + return ( |
| 45 | + <main className="homeWrap homeSection"> |
| 46 | + <Heading level={1} type="display-3">{error}</Heading> |
| 47 | + <div style={{ marginTop: 18 }}> |
| 48 | + <Button as="a" href={appPath("/learn")} variant="secondary" label="학습으로 돌아가기" icon={<ArrowLeft size={15} aria-hidden="true" />} /> |
| 49 | + </div> |
| 50 | + </main> |
| 51 | + ); |
| 52 | + } |
| 53 | + |
| 54 | + if (!lesson) { |
| 55 | + return ( |
| 56 | + <main className="homeWrap homeSection"> |
| 57 | + <Text type="body" color="muted">레슨을 불러오는 중...</Text> |
| 58 | + </main> |
| 59 | + ); |
| 60 | + } |
| 61 | + |
| 62 | + const firstSnippet = lesson.sections.find((s) => s.snippet)?.snippet; |
| 63 | + |
| 64 | + return ( |
| 65 | + <main className="lessonPage"> |
| 66 | + <section className="homeWrap homeSection" style={{ paddingBlock: "40px 24px" }}> |
| 67 | + <Button as="a" href={appPath("/learn")} variant="ghost" size="sm" label="학습" icon={<ArrowLeft size={15} aria-hidden="true" />} /> |
| 68 | + <div className="homeSectionHead" style={{ marginTop: 12 }}> |
| 69 | + <Text type="label" color="accent">{lesson.emoji ? `${lesson.emoji} ` : ""}레슨</Text> |
| 70 | + <Heading level={1} type="display-2">{lesson.title}</Heading> |
| 71 | + {lesson.direction && <Text type="body" color="muted">{lesson.direction}</Text>} |
| 72 | + </div> |
| 73 | + {lesson.intro && lesson.intro.points && lesson.intro.points.length > 0 && ( |
| 74 | + <ul className="lessonPoints"> |
| 75 | + {lesson.intro.points.map((p, i) => <li key={i}>{p}</li>)} |
| 76 | + </ul> |
| 77 | + )} |
| 78 | + <div id="lesson-runner" style={{ marginTop: 20 }}> |
| 79 | + <PythonRunner load={loaded} initialCode={firstSnippet || "# 이 레슨의 코드를 실행해보세요\nprint('안녕하세요')"} /> |
| 80 | + </div> |
| 81 | + </section> |
| 82 | + |
| 83 | + <section className="homeWrap homeSection" style={{ paddingTop: 8 }}> |
| 84 | + <div className="lessonSections"> |
| 85 | + {lesson.sections.map((s, i) => ( |
| 86 | + <Card key={i} padding={5}> |
| 87 | + <div className="homeCardStack"> |
| 88 | + {s.title && <Heading level={2} type="title">{s.title}</Heading>} |
| 89 | + {s.subtitle && <Text type="body-sm" color="subtle">{s.subtitle}</Text>} |
| 90 | + {s.why && <Text type="body-sm" color="muted">{s.why}</Text>} |
| 91 | + {s.explanation && <Text type="body" style={{ whiteSpace: "pre-wrap" }}>{s.explanation}</Text>} |
| 92 | + {s.snippet && ( |
| 93 | + <div className="lessonCell"> |
| 94 | + <pre className="lessonSnippet">{s.snippet}</pre> |
| 95 | + <Button variant="primary" size="sm" label="이 코드 실행" icon={<Play size={14} aria-hidden="true" />} clickAction={() => runCell(s.snippet)} /> |
| 96 | + </div> |
| 97 | + )} |
| 98 | + </div> |
| 99 | + </Card> |
| 100 | + ))} |
| 101 | + </div> |
| 102 | + </section> |
| 103 | + </main> |
| 104 | + ); |
| 105 | +} |
0 commit comments