Initial Robot U site prototype

This commit is contained in:
Kacper 2026-04-08 06:03:48 -04:00
commit fe19f200d7
27 changed files with 3677 additions and 0 deletions

674
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,674 @@
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>
);
}