Initial Robot U site prototype
This commit is contained in:
commit
fe19f200d7
27 changed files with 3677 additions and 0 deletions
674
frontend/src/App.tsx
Normal file
674
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue