675 lines
18 KiB
TypeScript
675 lines
18 KiB
TypeScript
|
|
import { useEffect, useState } from "preact/hooks";
|
||
|
|
|
||
|
|
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
|
||
|
|
import type {
|
||
|
|
CourseCard,
|
||
|
|
CourseChapter,
|
||
|
|
CourseLesson,
|
||
|
|
DiscussionCard,
|
||
|
|
DiscussionReply,
|
||
|
|
EventCard,
|
||
|
|
PostCard,
|
||
|
|
PrototypeData,
|
||
|
|
} from "./types";
|
||
|
|
|
||
|
|
function formatTimestamp(value: string): string {
|
||
|
|
if (!value) {
|
||
|
|
return "Unknown time";
|
||
|
|
}
|
||
|
|
|
||
|
|
const date = new Date(value);
|
||
|
|
if (Number.isNaN(date.getTime())) {
|
||
|
|
return "Unknown time";
|
||
|
|
}
|
||
|
|
|
||
|
|
return new Intl.DateTimeFormat("en-US", {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
hour: "numeric",
|
||
|
|
minute: "2-digit",
|
||
|
|
}).format(date);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizePathname(pathname: string): string {
|
||
|
|
if (!pathname || pathname === "/") {
|
||
|
|
return "/";
|
||
|
|
}
|
||
|
|
|
||
|
|
return pathname.replace(/\/+$/, "") || "/";
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseDiscussionRoute(pathname: string): number | null {
|
||
|
|
const match = normalizePathname(pathname).match(/^\/discussions\/(\d+)$/);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const issueId = Number(match[1]);
|
||
|
|
return Number.isFinite(issueId) ? issueId : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
|
||
|
|
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
owner: decodeURIComponent(match[1]),
|
||
|
|
repo: decodeURIComponent(match[2]),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseLessonRoute(pathname: string): {
|
||
|
|
owner: string;
|
||
|
|
repo: string;
|
||
|
|
chapter: string;
|
||
|
|
lesson: string;
|
||
|
|
} | null {
|
||
|
|
const match = normalizePathname(pathname).match(
|
||
|
|
/^\/courses\/([^/]+)\/([^/]+)\/lessons\/([^/]+)\/([^/]+)$/,
|
||
|
|
);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
owner: decodeURIComponent(match[1]),
|
||
|
|
repo: decodeURIComponent(match[2]),
|
||
|
|
chapter: decodeURIComponent(match[3]),
|
||
|
|
lesson: decodeURIComponent(match[4]),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeRouteKey(value: string): string {
|
||
|
|
return decodeURIComponent(value).trim().toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function findCourseByRoute(
|
||
|
|
courses: CourseCard[],
|
||
|
|
route: { owner: string; repo: string },
|
||
|
|
): CourseCard | undefined {
|
||
|
|
const routeOwner = normalizeRouteKey(route.owner);
|
||
|
|
const routeRepo = normalizeRouteKey(route.repo);
|
||
|
|
const routeFullName = `${routeOwner}/${routeRepo}`;
|
||
|
|
|
||
|
|
return courses.find((course) => {
|
||
|
|
const [repoOwner = "", repoName = ""] = course.repo.split("/", 2);
|
||
|
|
const courseOwner = normalizeRouteKey(course.owner || repoOwner);
|
||
|
|
const courseName = normalizeRouteKey(course.name || repoName);
|
||
|
|
const courseFullName = normalizeRouteKey(course.repo);
|
||
|
|
|
||
|
|
return (
|
||
|
|
(courseOwner === routeOwner && courseName === routeRepo) ||
|
||
|
|
courseFullName === routeFullName ||
|
||
|
|
courseName === routeRepo
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function findLessonByRoute(
|
||
|
|
course: CourseCard,
|
||
|
|
route: { chapter: string; lesson: string },
|
||
|
|
): { chapter: CourseChapter; lesson: CourseLesson } | undefined {
|
||
|
|
const routeChapter = normalizeRouteKey(route.chapter);
|
||
|
|
const routeLesson = normalizeRouteKey(route.lesson);
|
||
|
|
|
||
|
|
for (const chapter of course.outline) {
|
||
|
|
if (normalizeRouteKey(chapter.slug) !== routeChapter) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const lesson = chapter.lessons.find((entry) => normalizeRouteKey(entry.slug) === routeLesson);
|
||
|
|
if (lesson) {
|
||
|
|
return { chapter, lesson };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function usePathname() {
|
||
|
|
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
function handlePopState() {
|
||
|
|
setPathname(normalizePathname(window.location.pathname));
|
||
|
|
}
|
||
|
|
|
||
|
|
window.addEventListener("popstate", handlePopState);
|
||
|
|
return () => {
|
||
|
|
window.removeEventListener("popstate", handlePopState);
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
function navigate(nextPath: string) {
|
||
|
|
const normalized = normalizePathname(nextPath);
|
||
|
|
if (normalized === pathname) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
window.history.pushState({}, "", normalized);
|
||
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
||
|
|
setPathname(normalized);
|
||
|
|
}
|
||
|
|
|
||
|
|
return { pathname, navigate };
|
||
|
|
}
|
||
|
|
|
||
|
|
function SectionHeader(props: { title: string }) {
|
||
|
|
return (
|
||
|
|
<header className="section-header">
|
||
|
|
<h2>{props.title}</h2>
|
||
|
|
</header>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function EmptyState(props: { copy: string }) {
|
||
|
|
return <p className="empty-state">{props.copy}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
|
||
|
|
const { course, onOpenCourse } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="card course-card-button"
|
||
|
|
onClick={() => {
|
||
|
|
onOpenCourse(course);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<h3>{course.title}</h3>
|
||
|
|
<p className="muted-copy">{course.summary}</p>
|
||
|
|
<p className="meta-line">
|
||
|
|
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
|
||
|
|
</p>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function PostItem(props: { post: PostCard }) {
|
||
|
|
const { post } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article className="card">
|
||
|
|
<h3>{post.title}</h3>
|
||
|
|
<p className="muted-copy">{post.summary}</p>
|
||
|
|
<p className="meta-line">{post.repo}</p>
|
||
|
|
</article>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function EventItem(props: { event: EventCard }) {
|
||
|
|
const { event } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article className="card">
|
||
|
|
<h3>{event.title}</h3>
|
||
|
|
<p className="muted-copy">{event.when}</p>
|
||
|
|
<p className="meta-line">{event.source}</p>
|
||
|
|
</article>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DiscussionPreviewItem(props: {
|
||
|
|
discussion: DiscussionCard;
|
||
|
|
onOpenDiscussion: (id: number) => void;
|
||
|
|
}) {
|
||
|
|
const { discussion, onOpenDiscussion } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="discussion-preview-card"
|
||
|
|
onClick={() => {
|
||
|
|
onOpenDiscussion(discussion.id);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<h3>{discussion.title}</h3>
|
||
|
|
<p className="meta-line">
|
||
|
|
{discussion.repo} · {discussion.author} · {formatTimestamp(discussion.updated_at)} ·{" "}
|
||
|
|
{discussion.replies} replies
|
||
|
|
</p>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DiscussionReplyCard(props: { reply: DiscussionReply }) {
|
||
|
|
const { reply } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article className="reply-card">
|
||
|
|
<p className="reply-author">{reply.author}</p>
|
||
|
|
<p className="meta-line">{formatTimestamp(reply.created_at)}</p>
|
||
|
|
<MarkdownContent markdown={reply.body} className="thread-copy" />
|
||
|
|
</article>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ComposeBox() {
|
||
|
|
return (
|
||
|
|
<form
|
||
|
|
className="compose-box"
|
||
|
|
onSubmit={(event) => {
|
||
|
|
event.preventDefault();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<textarea className="compose-input" placeholder="Write a reply" />
|
||
|
|
<div className="compose-actions">
|
||
|
|
<button type="submit" className="compose-button" disabled>
|
||
|
|
Post reply
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function CoursePage(props: {
|
||
|
|
course: CourseCard;
|
||
|
|
onGoHome: () => void;
|
||
|
|
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
||
|
|
}) {
|
||
|
|
const { course, onGoHome, onOpenLesson } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section className="thread-view">
|
||
|
|
<button type="button" className="back-link" onClick={onGoHome}>
|
||
|
|
Back to courses
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="thread-header">
|
||
|
|
<h1>{course.title}</h1>
|
||
|
|
<p className="meta-line">
|
||
|
|
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
<p className="muted-copy">{course.summary}</p>
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="subsection-header">
|
||
|
|
<h2>Course outline</h2>
|
||
|
|
</header>
|
||
|
|
{course.outline.length > 0 ? (
|
||
|
|
<div className="outline-list">
|
||
|
|
{course.outline.map((chapter) => (
|
||
|
|
<section key={chapter.slug} className="outline-chapter">
|
||
|
|
<h3>{chapter.title}</h3>
|
||
|
|
<div className="lesson-list">
|
||
|
|
{chapter.lessons.map((lesson, index) => (
|
||
|
|
<button
|
||
|
|
key={lesson.path}
|
||
|
|
type="button"
|
||
|
|
className="lesson-row lesson-row-button"
|
||
|
|
onClick={() => {
|
||
|
|
onOpenLesson(course, chapter, lesson);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<p className="lesson-index">{index + 1 < 10 ? `0${index + 1}` : index + 1}</p>
|
||
|
|
<div>
|
||
|
|
<p className="lesson-title">{lesson.title}</p>
|
||
|
|
{lesson.summary ? <p className="lesson-summary">{lesson.summary}</p> : null}
|
||
|
|
<p className="meta-line">{lesson.file_path || lesson.path}</p>
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="This course repo has no lesson folders yet." />
|
||
|
|
)}
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function LessonPage(props: {
|
||
|
|
course: CourseCard;
|
||
|
|
chapter: CourseChapter;
|
||
|
|
lesson: CourseLesson;
|
||
|
|
onGoCourse: () => void;
|
||
|
|
}) {
|
||
|
|
const { course, chapter, lesson, onGoCourse } = props;
|
||
|
|
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section className="thread-view">
|
||
|
|
<button type="button" className="back-link" onClick={onGoCourse}>
|
||
|
|
Back to course
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="thread-header">
|
||
|
|
<h1>{lesson.title}</h1>
|
||
|
|
<p className="meta-line">
|
||
|
|
{course.repo} · {chapter.title}
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
{lesson.summary ? <p className="muted-copy">{lesson.summary}</p> : null}
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="subsection-header">
|
||
|
|
<h2>Lesson</h2>
|
||
|
|
</header>
|
||
|
|
{lessonBody ? (
|
||
|
|
<MarkdownContent markdown={lessonBody} className="lesson-body" />
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
|
||
|
|
)}
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => void }) {
|
||
|
|
const { discussion, onGoHome } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section className="thread-view">
|
||
|
|
<button type="button" className="back-link" onClick={onGoHome}>
|
||
|
|
Back to discussions
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="thread-header">
|
||
|
|
<h1>{discussion.title}</h1>
|
||
|
|
<p className="meta-line">
|
||
|
|
{discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "}
|
||
|
|
{formatTimestamp(discussion.updated_at)}
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
<MarkdownContent markdown={discussion.body} className="thread-copy" />
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="subsection-header">
|
||
|
|
<h2>Replies</h2>
|
||
|
|
<p className="meta-line">{discussion.comments.length}</p>
|
||
|
|
</header>
|
||
|
|
{discussion.comments.length > 0 ? (
|
||
|
|
<div className="reply-list">
|
||
|
|
{discussion.comments.map((reply) => (
|
||
|
|
<DiscussionReplyCard key={reply.id} reply={reply} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="No replies yet." />
|
||
|
|
)}
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<article className="panel">
|
||
|
|
<header className="subsection-header">
|
||
|
|
<h2>Reply</h2>
|
||
|
|
</header>
|
||
|
|
<ComposeBox />
|
||
|
|
</article>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function HomeView(props: {
|
||
|
|
data: PrototypeData;
|
||
|
|
onOpenCourse: (course: CourseCard) => void;
|
||
|
|
onOpenDiscussion: (id: number) => void;
|
||
|
|
}) {
|
||
|
|
const { data, onOpenCourse, onOpenDiscussion } = props;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<section className="page-header">
|
||
|
|
<p className="page-kicker">Robot U</p>
|
||
|
|
<h1>Courses, projects, and discussions.</h1>
|
||
|
|
<p className="muted-copy">
|
||
|
|
A single place for lessons, member work, and community conversation.
|
||
|
|
</p>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="page-section">
|
||
|
|
<SectionHeader title="Courses" />
|
||
|
|
{data.featured_courses.length > 0 ? (
|
||
|
|
<div className="card-grid">
|
||
|
|
{data.featured_courses.map((course) => (
|
||
|
|
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="No public repos with `/lessons/` were found." />
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="two-column-grid">
|
||
|
|
<div className="page-section">
|
||
|
|
<SectionHeader title="Posts" />
|
||
|
|
{data.recent_posts.length > 0 ? (
|
||
|
|
<div className="stack">
|
||
|
|
{data.recent_posts.map((post) => (
|
||
|
|
<PostItem key={`${post.repo}:${post.title}`} post={post} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="No public repos with `/blogs/` were found." />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="page-section">
|
||
|
|
<SectionHeader title="Events" />
|
||
|
|
{data.upcoming_events.length > 0 ? (
|
||
|
|
<div className="stack">
|
||
|
|
{data.upcoming_events.map((event) => (
|
||
|
|
<EventItem key={`${event.source}:${event.title}`} event={event} />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="ICS feeds are not configured yet." />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="page-section">
|
||
|
|
<SectionHeader title="Discussions" />
|
||
|
|
{data.recent_discussions.length > 0 ? (
|
||
|
|
<div className="stack">
|
||
|
|
{data.recent_discussions.map((discussion) => (
|
||
|
|
<DiscussionPreviewItem
|
||
|
|
key={discussion.id}
|
||
|
|
discussion={discussion}
|
||
|
|
onOpenDiscussion={onOpenDiscussion}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<EmptyState copy="No visible Forgejo issues were returned for this account." />
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function AppContent(props: {
|
||
|
|
data: PrototypeData;
|
||
|
|
pathname: string;
|
||
|
|
onOpenCourse: (course: CourseCard) => void;
|
||
|
|
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
|
||
|
|
onOpenDiscussion: (id: number) => void;
|
||
|
|
onGoHome: () => void;
|
||
|
|
}) {
|
||
|
|
const lessonRoute = parseLessonRoute(props.pathname);
|
||
|
|
if (lessonRoute !== null) {
|
||
|
|
const selectedCourse = findCourseByRoute(props.data.featured_courses, lessonRoute);
|
||
|
|
if (!selectedCourse) {
|
||
|
|
return (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Course not found.</h1>
|
||
|
|
<button type="button" className="back-link" onClick={props.onGoHome}>
|
||
|
|
Back to courses
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedLesson = findLessonByRoute(selectedCourse, lessonRoute);
|
||
|
|
if (!selectedLesson) {
|
||
|
|
return (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Lesson not found.</h1>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="back-link"
|
||
|
|
onClick={() => {
|
||
|
|
props.onOpenCourse(selectedCourse);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Back to course
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<LessonPage
|
||
|
|
course={selectedCourse}
|
||
|
|
chapter={selectedLesson.chapter}
|
||
|
|
lesson={selectedLesson.lesson}
|
||
|
|
onGoCourse={() => {
|
||
|
|
props.onOpenCourse(selectedCourse);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const courseRoute = parseCourseRoute(props.pathname);
|
||
|
|
if (courseRoute !== null) {
|
||
|
|
const selectedCourse = findCourseByRoute(props.data.featured_courses, courseRoute);
|
||
|
|
if (!selectedCourse) {
|
||
|
|
return (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Course not found.</h1>
|
||
|
|
<button type="button" className="back-link" onClick={props.onGoHome}>
|
||
|
|
Back to courses
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<CoursePage
|
||
|
|
course={selectedCourse}
|
||
|
|
onGoHome={props.onGoHome}
|
||
|
|
onOpenLesson={props.onOpenLesson}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const discussionId = parseDiscussionRoute(props.pathname);
|
||
|
|
if (discussionId === null) {
|
||
|
|
return (
|
||
|
|
<HomeView
|
||
|
|
data={props.data}
|
||
|
|
onOpenCourse={props.onOpenCourse}
|
||
|
|
onOpenDiscussion={props.onOpenDiscussion}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedDiscussion = props.data.recent_discussions.find(
|
||
|
|
(discussion) => discussion.id === discussionId,
|
||
|
|
);
|
||
|
|
if (!selectedDiscussion) {
|
||
|
|
return (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Discussion not found.</h1>
|
||
|
|
<button type="button" className="back-link" onClick={props.onGoHome}>
|
||
|
|
Back to discussions
|
||
|
|
</button>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return <DiscussionPage discussion={selectedDiscussion} onGoHome={props.onGoHome} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function App() {
|
||
|
|
const [data, setData] = useState<PrototypeData | null>(null);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const { pathname, navigate } = usePathname();
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const controller = new AbortController();
|
||
|
|
|
||
|
|
async function loadPrototype() {
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/prototype", {
|
||
|
|
signal: controller.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Prototype request failed with ${response.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const payload = (await response.json()) as PrototypeData;
|
||
|
|
setData(payload);
|
||
|
|
} catch (loadError) {
|
||
|
|
if (controller.signal.aborted) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const message =
|
||
|
|
loadError instanceof Error ? loadError.message : "Unknown prototype loading error";
|
||
|
|
setError(message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
loadPrototype();
|
||
|
|
return () => {
|
||
|
|
controller.abort();
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
function openDiscussion(id: number) {
|
||
|
|
navigate(`/discussions/${id}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openCourse(course: CourseCard) {
|
||
|
|
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
|
||
|
|
navigate(
|
||
|
|
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function goHome() {
|
||
|
|
navigate("/");
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<main className="app-shell">
|
||
|
|
{error ? (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Backend data did not load.</h1>
|
||
|
|
<p className="muted-copy">{error}</p>
|
||
|
|
</section>
|
||
|
|
) : data ? (
|
||
|
|
<AppContent
|
||
|
|
data={data}
|
||
|
|
pathname={pathname}
|
||
|
|
onOpenCourse={openCourse}
|
||
|
|
onOpenLesson={openLesson}
|
||
|
|
onOpenDiscussion={openDiscussion}
|
||
|
|
onGoHome={goHome}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<section className="page-message">
|
||
|
|
<h1>Loading content.</h1>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
</main>
|
||
|
|
);
|
||
|
|
}
|