robot-u-site/frontend/src/App.tsx

1378 lines
38 KiB
TypeScript
Raw Normal View History

2026-04-08 06:03:48 -04:00
import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
AuthState,
2026-04-08 06:03:48 -04:00
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 isSignInRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/signin";
}
function isCoursesIndexRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/courses";
}
function isDiscussionsIndexRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/discussions";
}
function isActivityRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/activity";
}
2026-04-08 06:03:48 -04:00
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]),
};
}
2026-04-12 20:23:05 -04:00
function parsePostRoute(pathname: string): { owner: string; repo: string; post: string } | null {
const match = normalizePathname(pathname).match(/^\/posts\/([^/]+)\/([^/]+)\/([^/]+)$/);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
post: decodeURIComponent(match[3]),
};
}
2026-04-08 06:03:48 -04:00
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
);
});
}
2026-04-12 20:23:05 -04:00
function findPostByRoute(
posts: PostCard[],
route: { owner: string; repo: string; post: string },
): PostCard | undefined {
const routeOwner = normalizeRouteKey(route.owner);
const routeRepo = normalizeRouteKey(route.repo);
const routePost = normalizeRouteKey(route.post);
const routeFullName = `${routeOwner}/${routeRepo}`;
return posts.find((post) => {
const postOwner = normalizeRouteKey(post.owner);
const postRepo = normalizeRouteKey(post.name);
const postFullName = normalizeRouteKey(post.repo);
return (
postOwner === routeOwner &&
(postRepo === routeRepo || postFullName === routeFullName) &&
normalizeRouteKey(post.slug) === routePost
);
});
}
2026-04-08 06:03:48 -04:00
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 nextUrl = new URL(nextPath, window.location.origin);
const normalized = normalizePathname(nextUrl.pathname);
const renderedPath = `${normalized}${nextUrl.search}`;
if (
normalized === pathname &&
renderedPath === `${window.location.pathname}${window.location.search}`
) {
2026-04-08 06:03:48 -04:00
return;
}
window.history.pushState({}, "", renderedPath);
2026-04-08 06:03:48 -04:00
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 canUseInteractiveAuth(auth: AuthState): boolean {
return auth.authenticated && auth.can_reply;
}
function forgejoSignInUrl(returnTo: string): string {
const target = new URL("/api/auth/forgejo/start", window.location.origin);
target.searchParams.set("return_to", returnTo);
return `${target.pathname}${target.search}`;
}
function TopBar(props: {
auth: AuthState;
pathname: string;
onGoHome: () => void;
onGoCourses: () => void;
onGoDiscussions: () => void;
onGoActivity: () => void;
onGoSignIn: () => void;
onSignOut: () => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const {
auth,
pathname,
onGoHome,
onGoCourses,
onGoDiscussions,
onGoActivity,
onGoSignIn,
onSignOut,
} = props;
function navClass(targetPath: string) {
const normalized = normalizePathname(pathname);
const isActive =
targetPath === "/"
? normalized === "/"
: normalized === targetPath || normalized.startsWith(`${targetPath}/`);
return isActive ? "topbar-link active" : "topbar-link";
}
const normalizedPathname = normalizePathname(pathname);
const brandClass = normalizedPathname === "/" ? "topbar-brand active" : "topbar-brand";
const menuClass = isMenuOpen ? "topbar-menu open" : "topbar-menu";
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
function handleNavigation(callback: () => void) {
setIsMenuOpen(false);
callback();
}
return (
<header className="topbar">
<button
type="button"
className={brandClass}
aria-label="Go to Robot U home"
onClick={() => {
handleNavigation(onGoHome);
}}
>
Robot U
</button>
<button
type="button"
className="topbar-menu-button"
aria-controls="topbar-menu"
aria-expanded={isMenuOpen}
aria-label="Toggle navigation menu"
onClick={() => {
setIsMenuOpen((currentValue) => !currentValue);
}}
>
<span aria-hidden="true" />
<span aria-hidden="true" />
<span aria-hidden="true" />
</button>
<div id="topbar-menu" className={menuClass}>
<nav className="topbar-nav" aria-label="Primary navigation">
<button
type="button"
className={navClass("/courses")}
onClick={() => {
handleNavigation(onGoCourses);
}}
>
Courses
</button>
<button
type="button"
className={navClass("/discussions")}
onClick={() => {
handleNavigation(onGoDiscussions);
}}
>
Discussions
</button>
<button
type="button"
className={navClass("/activity")}
onClick={() => {
handleNavigation(onGoActivity);
}}
>
Activity
</button>
</nav>
<div className="topbar-auth">
{auth.authenticated ? (
<p>{auth.login}</p>
) : (
<p className="topbar-auth-muted">Not signed in</p>
)}
{auth.authenticated ? (
<button
type="button"
className="secondary-button"
onClick={() => {
handleNavigation(onSignOut);
}}
>
Sign out
</button>
) : (
<button
type="button"
className="secondary-button"
onClick={() => {
handleNavigation(onGoSignIn);
}}
>
Sign in
</button>
)}
</div>
</div>
</header>
);
}
2026-04-08 06:03:48 -04:00
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>
);
}
2026-04-12 20:23:05 -04:00
function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void }) {
const { post, onOpenPost } = props;
2026-04-08 06:03:48 -04:00
return (
2026-04-12 20:23:05 -04:00
<button
type="button"
className="card post-card-button"
onClick={() => {
onOpenPost(post);
}}
>
2026-04-08 06:03:48 -04:00
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
2026-04-12 20:23:05 -04:00
<p className="meta-line">
{post.repo} · {post.file_path || post.path}
</p>
</button>
2026-04-08 06:03:48 -04:00
);
}
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 repoParts(fullName: string): { owner: string; repo: string } | null {
const [owner, repo] = fullName.split("/", 2);
if (!owner || !repo) {
return null;
}
return { owner, repo };
}
async function responseError(response: Response, fallback: string): Promise<Error> {
const payload = (await response.json().catch(() => null)) as { detail?: string } | null;
return new Error(payload?.detail || fallback);
}
async function postDiscussionReply(
discussion: DiscussionCard,
body: string,
): Promise<DiscussionReply> {
const repo = repoParts(discussion.repo);
if (!repo) {
throw new Error("This discussion is missing repository information.");
}
const response = await fetch("/api/discussions/replies", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
owner: repo.owner,
repo: repo.repo,
number: discussion.number,
body,
}),
});
if (!response.ok) {
throw await responseError(response, `Reply failed with ${response.status}`);
}
return (await response.json()) as DiscussionReply;
}
function ComposeBox(props: {
discussion: DiscussionCard;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
auth: AuthState;
onGoSignIn: () => void;
}) {
const { discussion, onReplyCreated, auth, onGoSignIn } = props;
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedBody = body.trim();
const canReply = canUseInteractiveAuth(auth);
async function submitReply(event: SubmitEvent) {
event.preventDefault();
if (!canReply) {
setError("Sign in before replying.");
return;
}
if (!trimmedBody || isSubmitting) {
return;
}
setError(null);
setIsSubmitting(true);
try {
const reply = await postDiscussionReply(discussion, trimmedBody);
onReplyCreated(discussion.id, reply);
setBody("");
} catch (replyError) {
const message =
replyError instanceof Error ? replyError.message : "Reply could not be posted.";
setError(message);
} finally {
setIsSubmitting(false);
}
}
2026-04-08 06:03:48 -04:00
return (
<form className="compose-box" onSubmit={submitReply}>
{!canReply ? (
<div className="signin-callout">
<p>
{auth.authenticated
? "Reply posting is unavailable for this session."
: "Sign in before replying."}
</p>
{!auth.authenticated ? (
<button type="button" className="secondary-button" onClick={onGoSignIn}>
Sign in
</button>
) : null}
</div>
) : null}
<textarea
className="compose-input"
placeholder="Write a reply"
value={body}
disabled={!canReply}
onInput={(event) => {
setBody(event.currentTarget.value);
}}
/>
2026-04-08 06:03:48 -04:00
<div className="compose-actions">
<button
type="submit"
className="compose-button"
disabled={!canReply || !trimmedBody || isSubmitting}
>
{isSubmitting ? "Posting..." : "Post reply"}
2026-04-08 06:03:48 -04:00
</button>
</div>
{error ? <p className="compose-error">{error}</p> : null}
2026-04-08 06:03:48 -04:00
</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>
);
}
2026-04-12 21:19:09 -04:00
function PostPage(props: { post: PostCard }) {
const { post } = props;
2026-04-12 20:23:05 -04:00
const postBody = stripLeadingTitleHeading(post.body, post.title);
return (
<section className="thread-view">
<article className="panel">
<header className="thread-header">
<h1>{post.title}</h1>
<p className="meta-line">
{post.repo} · {formatTimestamp(post.updated_at)}
</p>
</header>
{post.summary ? <p className="muted-copy">{post.summary}</p> : null}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Post</h2>
</header>
{postBody ? (
<MarkdownContent markdown={postBody} className="thread-copy" />
) : (
<EmptyState copy="This post file is empty or could not be read from Forgejo." />
)}
</article>
</section>
);
}
function DiscussionPage(props: {
discussion: DiscussionCard;
auth: AuthState;
onGoHome: () => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
}) {
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
2026-04-08 06:03:48 -04:00
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
discussion={discussion}
auth={auth}
onGoSignIn={onGoSignIn}
onReplyCreated={onReplyCreated}
/>
2026-04-08 06:03:48 -04:00
</article>
</section>
);
}
function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: CourseCard) => void }) {
const { data, onOpenCourse } = props;
return (
<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>
);
}
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
const { data, onOpenDiscussion } = props;
return (
<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>
);
}
2026-04-08 06:03:48 -04:00
function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
2026-04-12 20:23:05 -04:00
onOpenPost: (post: PostCard) => void;
2026-04-08 06:03:48 -04:00
onOpenDiscussion: (id: number) => void;
}) {
2026-04-12 20:23:05 -04:00
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
2026-04-08 06:03:48 -04:00
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>
<CoursesView data={data} onOpenCourse={onOpenCourse} />
2026-04-08 06:03:48 -04:00
<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) => (
2026-04-12 20:23:05 -04:00
<PostItem key={`${post.repo}:${post.slug}`} post={post} onOpenPost={onOpenPost} />
2026-04-08 06:03:48 -04:00
))}
</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>
<DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} />
2026-04-08 06:03:48 -04:00
</>
);
}
2026-04-12 21:19:09 -04:00
function SignInPage(props: { auth: AuthState }) {
const { auth } = props;
const query = new URLSearchParams(window.location.search);
const error = query.get("error");
const returnTo = query.get("return_to") || "/";
function startForgejoSignIn() {
window.location.assign(forgejoSignInUrl(returnTo));
}
return (
<section className="signin-page">
<article className="panel signin-panel">
<header className="thread-header">
<h1>Sign in</h1>
<p className="muted-copy">
Forgejo sign-in needs attention. You can retry the OAuth flow or return to the site.
</p>
</header>
<div className="signin-actions">
2026-04-08 06:03:48 -04:00
<button
type="button"
className="compose-button"
disabled={!auth.oauth_configured}
onClick={startForgejoSignIn}
2026-04-08 06:03:48 -04:00
>
Continue with Forgejo
2026-04-08 06:03:48 -04:00
</button>
{!auth.oauth_configured ? (
<p className="compose-error">Forgejo OAuth is not configured on this backend yet.</p>
) : null}
{error ? <p className="compose-error">{error}</p> : null}
</div>
</article>
</section>
);
}
async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData> {
const response = await fetch("/api/prototype", {
signal,
});
if (!response.ok) {
throw new Error(`Prototype request failed with ${response.status}`);
}
return (await response.json()) as PrototypeData;
}
function appendDiscussionReply(
currentData: PrototypeData | null,
discussionId: number,
reply: DiscussionReply,
): PrototypeData | null {
if (!currentData) {
return currentData;
}
return {
...currentData,
recent_discussions: currentData.recent_discussions.map((discussion) => {
if (discussion.id !== discussionId) {
return discussion;
}
return {
...discussion,
replies: discussion.replies + 1,
updated_at: reply.created_at || discussion.updated_at,
comments: [...discussion.comments, reply],
};
}),
};
}
interface ActivityEntry {
id: string;
title: string;
detail: string;
timestamp: string;
route: string | null;
}
function timestampMillis(value: string): number {
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function coursePath(course: CourseCard): string {
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`;
}
2026-04-12 20:23:05 -04:00
function postPath(post: PostCard): string {
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
}
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = [];
for (const course of data.featured_courses) {
if (course.updated_at) {
activities.push({
id: `course:${course.repo}`,
title: `Course updated: ${course.title}`,
detail: `${course.repo} · ${course.lessons} lessons`,
timestamp: course.updated_at,
route: coursePath(course),
});
}
}
for (const post of data.recent_posts) {
if (post.updated_at) {
activities.push({
2026-04-12 20:23:05 -04:00
id: `post:${post.repo}:${post.slug}`,
title: `Post updated: ${post.title}`,
detail: post.repo,
timestamp: post.updated_at,
2026-04-12 20:23:05 -04:00
route: postPath(post),
});
2026-04-08 06:03:48 -04:00
}
}
for (const discussion of data.recent_discussions) {
activities.push({
id: `discussion:${discussion.id}`,
title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} · ${discussion.replies} replies`,
timestamp: discussion.updated_at,
route: `/discussions/${discussion.id}`,
});
for (const reply of discussion.comments) {
activities.push({
id: `reply:${discussion.id}:${reply.id}`,
title: `${reply.author} replied`,
detail: discussion.title,
timestamp: reply.created_at,
route: `/discussions/${discussion.id}`,
});
}
}
return activities.sort(
(left, right) => timestampMillis(right.timestamp) - timestampMillis(left.timestamp),
);
}
function ActivityItem(props: { entry: ActivityEntry; onOpenRoute: (route: string) => void }) {
const { entry, onOpenRoute } = props;
2026-04-08 06:03:48 -04:00
if (entry.route) {
2026-04-08 06:03:48 -04:00
return (
<button
type="button"
className="activity-card activity-card-button"
onClick={() => {
if (entry.route) {
onOpenRoute(entry.route);
}
2026-04-08 06:03:48 -04:00
}}
>
<h3>{entry.title}</h3>
<p className="muted-copy">{entry.detail}</p>
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
</button>
2026-04-08 06:03:48 -04:00
);
}
return (
<article className="activity-card">
<h3>{entry.title}</h3>
<p className="muted-copy">{entry.detail}</p>
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
</article>
);
}
function ActivityView(props: { data: PrototypeData; onOpenRoute: (route: string) => void }) {
const activities = buildActivityFeed(props.data);
return (
<section className="page-section">
<SectionHeader title="Activity" />
{activities.length > 0 ? (
<div className="activity-list">
{activities.map((entry) => (
<ActivityItem key={entry.id} entry={entry} onOpenRoute={props.onOpenRoute} />
))}
</div>
) : (
<EmptyState copy="No public site activity has been loaded yet." />
)}
</section>
);
}
interface AppContentProps {
data: PrototypeData;
pathname: string;
onOpenCourse: (course: CourseCard) => void;
2026-04-12 20:23:05 -04:00
onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onOpenRoute: (route: string) => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
onGoHome: () => void;
onGoCourses: () => void;
onGoDiscussions: () => void;
}
2026-04-08 06:03:48 -04:00
2026-04-12 20:23:05 -04:00
function PostRouteView(
props: AppContentProps & { route: { owner: string; repo: string; post: string } },
) {
const selectedPost = findPostByRoute(props.data.recent_posts, props.route);
if (!selectedPost) {
return (
<section className="page-message">
<h1>Post not found.</h1>
</section>
);
}
2026-04-12 21:19:09 -04:00
return <PostPage post={selectedPost} />;
2026-04-12 20:23:05 -04:00
}
function LessonRouteView(
props: AppContentProps & {
route: { owner: string; repo: string; chapter: string; lesson: string };
},
) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
if (!selectedCourse) {
2026-04-08 06:03:48 -04:00
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoCourses}>
Back to courses
</button>
</section>
2026-04-08 06:03:48 -04:00
);
}
const selectedLesson = findLessonByRoute(selectedCourse, props.route);
if (!selectedLesson) {
2026-04-08 06:03:48 -04:00
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);
}}
/>
);
}
function CourseRouteView(props: AppContentProps & { route: { owner: string; repo: string } }) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
if (!selectedCourse) {
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoCourses}>
Back to courses
</button>
</section>
2026-04-08 06:03:48 -04:00
);
}
return (
<CoursePage
course={selectedCourse}
onGoHome={props.onGoCourses}
onOpenLesson={props.onOpenLesson}
/>
);
}
function DiscussionRouteView(props: AppContentProps & { discussionId: number }) {
2026-04-08 06:03:48 -04:00
const selectedDiscussion = props.data.recent_discussions.find(
(discussion) => discussion.id === props.discussionId,
2026-04-08 06:03:48 -04:00
);
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Discussion not found.</h1>
<button type="button" className="back-link" onClick={props.onGoDiscussions}>
2026-04-08 06:03:48 -04:00
Back to discussions
</button>
</section>
);
}
return (
<DiscussionPage
discussion={selectedDiscussion}
auth={props.data.auth}
onGoHome={props.onGoDiscussions}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
/>
);
}
function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) {
2026-04-12 21:19:09 -04:00
return <SignInPage auth={props.data.auth} />;
}
if (isActivityRoute(props.pathname)) {
return <ActivityView data={props.data} onOpenRoute={props.onOpenRoute} />;
}
if (isCoursesIndexRoute(props.pathname)) {
return <CoursesView data={props.data} onOpenCourse={props.onOpenCourse} />;
}
if (isDiscussionsIndexRoute(props.pathname)) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
}
2026-04-12 20:23:05 -04:00
const postRoute = parsePostRoute(props.pathname);
if (postRoute !== null) {
return <PostRouteView {...props} route={postRoute} />;
}
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
return <LessonRouteView {...props} route={lessonRoute} />;
}
const courseRoute = parseCourseRoute(props.pathname);
if (courseRoute !== null) {
return <CourseRouteView {...props} route={courseRoute} />;
}
const discussionId = parseDiscussionRoute(props.pathname);
if (discussionId === null) {
return (
<HomeView
data={props.data}
onOpenCourse={props.onOpenCourse}
2026-04-12 20:23:05 -04:00
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
/>
);
}
return <DiscussionRouteView {...props} discussionId={discussionId} />;
}
function LoadedApp(
props: AppContentProps & {
onGoActivity: () => void;
onSignOut: () => void;
},
) {
return (
<>
<TopBar
auth={props.data.auth}
pathname={props.pathname}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
onGoActivity={props.onGoActivity}
onGoSignIn={props.onGoSignIn}
onSignOut={props.onSignOut}
/>
<main className="app-shell">
<AppContent
data={props.data}
pathname={props.pathname}
onOpenCourse={props.onOpenCourse}
2026-04-12 20:23:05 -04:00
onOpenPost={props.onOpenPost}
onOpenLesson={props.onOpenLesson}
onOpenDiscussion={props.onOpenDiscussion}
onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
/>
</main>
</>
);
}
function AppStatusPage(props: { title: string; copy?: string }) {
return (
<main className="app-shell">
<section className="page-message">
<h1>{props.title}</h1>
{props.copy ? <p className="muted-copy">{props.copy}</p> : null}
</section>
</main>
);
2026-04-08 06:03:48 -04:00
}
export default function App() {
const [data, setData] = useState<PrototypeData | null>(null);
const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
async function loadPrototype(signal?: AbortSignal) {
try {
const payload = await fetchPrototypeData(signal);
setData(payload);
setError(null);
} catch (loadError) {
if (signal?.aborted) {
return;
2026-04-08 06:03:48 -04:00
}
const message =
loadError instanceof Error ? loadError.message : "Unknown prototype loading error";
setError(message);
2026-04-08 06:03:48 -04:00
}
}
2026-04-08 06:03:48 -04:00
useEffect(() => {
const controller = new AbortController();
loadPrototype(controller.signal);
2026-04-08 06:03:48 -04:00
return () => {
controller.abort();
};
}, []);
2026-04-12 20:23:05 -04:00
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
2026-04-08 06:03:48 -04:00
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
}
2026-04-12 20:23:05 -04:00
const goCourses = () => navigate("/courses");
2026-04-12 20:23:05 -04:00
const goDiscussions = () => navigate("/discussions");
2026-04-12 20:23:05 -04:00
const goActivity = () => navigate("/activity");
2026-04-08 06:03:48 -04:00
function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
}
2026-04-12 20:23:05 -04:00
const openPost = (post: PostCard) => navigate(postPath(post));
2026-04-08 06:03:48 -04:00
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
}
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
2026-04-12 20:23:05 -04:00
const goHome = () => navigate("/");
2026-04-08 06:03:48 -04:00
async function signOut() {
await fetch("/api/auth/session", {
method: "DELETE",
});
await loadPrototype();
navigate("/");
}
if (error) {
return <AppStatusPage title="Backend data did not load." copy={error} />;
}
if (!data) {
return <AppStatusPage title="Loading content." />;
}
2026-04-08 06:03:48 -04:00
return (
<LoadedApp
data={data}
pathname={pathname}
onOpenCourse={openCourse}
2026-04-12 20:23:05 -04:00
onOpenPost={openPost}
onOpenLesson={openLesson}
onOpenDiscussion={openDiscussion}
onOpenRoute={navigate}
onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion}
onGoHome={goHome}
onGoCourses={goCourses}
onGoDiscussions={goDiscussions}
onGoActivity={goActivity}
onSignOut={signOut}
/>
2026-04-08 06:03:48 -04:00
);
}