import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
AuthState,
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";
}
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 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]),
};
}
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 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
);
});
}
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}`
) {
return;
}
window.history.pushState({}, "", renderedPath);
window.scrollTo({ top: 0, behavior: "auto" });
setPathname(normalized);
}
return { pathname, navigate };
}
function SectionHeader(props: { title: string }) {
return (
);
}
function EmptyState(props: { copy: string }) {
return
{props.copy}
;
}
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 (
);
}
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
const { course, onOpenCourse } = props;
return (
);
}
function PostItem(props: { post: PostCard; onOpenPost: (post: PostCard) => void }) {
const { post, onOpenPost } = props;
return (
);
}
function EventItem(props: { event: EventCard }) {
const { event } = props;
return (
{event.title}
{event.when}
{event.source}
);
}
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void;
}) {
const { discussion, onOpenDiscussion } = props;
return (
);
}
function DiscussionReplyCard(props: { reply: DiscussionReply }) {
const { reply } = props;
return (
{reply.author}
{formatTimestamp(reply.created_at)}
);
}
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 {
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 {
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(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);
}
}
return (
);
}
function CoursePage(props: {
course: CourseCard;
onGoHome: () => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
}) {
const { course, onGoHome, onOpenLesson } = props;
return (
{course.summary}
{course.outline.length > 0 ? (
{course.outline.map((chapter) => (
{chapter.title}
{chapter.lessons.map((lesson, index) => (
))}
))}
) : (
)}
);
}
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 (
{lesson.summary ? {lesson.summary}
: null}
{lessonBody ? (
) : (
)}
);
}
function PostPage(props: { post: PostCard }) {
const { post } = props;
const postBody = stripLeadingTitleHeading(post.body, post.title);
return (
{post.summary ? {post.summary}
: null}
{postBody ? (
) : (
)}
);
}
function DiscussionPage(props: {
discussion: DiscussionCard;
auth: AuthState;
onGoHome: () => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
}) {
const { discussion, auth, onGoHome, onGoSignIn, onReplyCreated } = props;
return (
{discussion.comments.length > 0 ? (
{discussion.comments.map((reply) => (
))}
) : (
)}
);
}
function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: CourseCard) => void }) {
const { data, onOpenCourse } = props;
return (
{data.featured_courses.length > 0 ? (
{data.featured_courses.map((course) => (
))}
) : (
)}
);
}
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
const { data, onOpenDiscussion } = props;
return (
{data.recent_discussions.length > 0 ? (
{data.recent_discussions.map((discussion) => (
))}
) : (
)}
);
}
function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenDiscussion: (id: number) => void;
}) {
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
return (
<>
Robot U
Courses, projects, and discussions.
A single place for lessons, member work, and community conversation.
{data.recent_posts.length > 0 ? (
{data.recent_posts.map((post) => (
))}
) : (
)}
{data.upcoming_events.length > 0 ? (
{data.upcoming_events.map((event) => (
))}
) : (
)}
>
);
}
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 (
{!auth.oauth_configured ? (
Forgejo OAuth is not configured on this backend yet.
) : null}
{error ?
{error}
: null}
);
}
async function fetchPrototypeData(signal?: AbortSignal): Promise {
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)}`;
}
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({
id: `post:${post.repo}:${post.slug}`,
title: `Post updated: ${post.title}`,
detail: post.repo,
timestamp: post.updated_at,
route: postPath(post),
});
}
}
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;
if (entry.route) {
return (
);
}
return (
{entry.title}
{entry.detail}
{formatTimestamp(entry.timestamp)}
);
}
function ActivityView(props: { data: PrototypeData; onOpenRoute: (route: string) => void }) {
const activities = buildActivityFeed(props.data);
return (
{activities.length > 0 ? (
{activities.map((entry) => (
))}
) : (
)}
);
}
interface AppContentProps {
data: PrototypeData;
pathname: string;
onOpenCourse: (course: CourseCard) => void;
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;
}
function PostRouteView(
props: AppContentProps & { route: { owner: string; repo: string; post: string } },
) {
const selectedPost = findPostByRoute(props.data.recent_posts, props.route);
if (!selectedPost) {
return (
);
}
return ;
}
function LessonRouteView(
props: AppContentProps & {
route: { owner: string; repo: string; chapter: string; lesson: string };
},
) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
if (!selectedCourse) {
return (
Course not found.
);
}
const selectedLesson = findLessonByRoute(selectedCourse, props.route);
if (!selectedLesson) {
return (
Lesson not found.
);
}
return (
{
props.onOpenCourse(selectedCourse);
}}
/>
);
}
function CourseRouteView(props: AppContentProps & { route: { owner: string; repo: string } }) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, props.route);
if (!selectedCourse) {
return (
Course not found.
);
}
return (
);
}
function DiscussionRouteView(props: AppContentProps & { discussionId: number }) {
const selectedDiscussion = props.data.recent_discussions.find(
(discussion) => discussion.id === props.discussionId,
);
if (!selectedDiscussion) {
return (
Discussion not found.
);
}
return (
);
}
function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) {
return ;
}
if (isActivityRoute(props.pathname)) {
return ;
}
if (isCoursesIndexRoute(props.pathname)) {
return ;
}
if (isDiscussionsIndexRoute(props.pathname)) {
return ;
}
const postRoute = parsePostRoute(props.pathname);
if (postRoute !== null) {
return ;
}
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
return ;
}
const courseRoute = parseCourseRoute(props.pathname);
if (courseRoute !== null) {
return ;
}
const discussionId = parseDiscussionRoute(props.pathname);
if (discussionId === null) {
return (
);
}
return ;
}
function LoadedApp(
props: AppContentProps & {
onGoActivity: () => void;
onSignOut: () => void;
},
) {
return (
<>
>
);
}
function AppStatusPage(props: { title: string; copy?: string }) {
return (
{props.title}
{props.copy ? {props.copy}
: null}
);
}
export default function App() {
const [data, setData] = useState(null);
const [error, setError] = useState(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;
}
const message =
loadError instanceof Error ? loadError.message : "Unknown prototype loading error";
setError(message);
}
}
useEffect(() => {
const controller = new AbortController();
loadPrototype(controller.signal);
return () => {
controller.abort();
};
}, []);
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
}
const goCourses = () => navigate("/courses");
const goDiscussions = () => navigate("/discussions");
const goActivity = () => navigate("/activity");
function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
}
const openPost = (post: PostCard) => navigate(postPath(post));
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));
}
const goHome = () => navigate("/");
async function signOut() {
await fetch("/api/auth/session", {
method: "DELETE",
});
await loadPrototype();
navigate("/");
}
if (error) {
return ;
}
if (!data) {
return ;
}
return (
);
}