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 (

{props.title}

); } 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 (
{auth.authenticated ? (

{auth.login}

) : (

Not signed in

)} {auth.authenticated ? ( ) : ( )}
); } 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 (
{!canReply ? (

{auth.authenticated ? "Reply posting is unavailable for this session." : "Sign in before replying."}

{!auth.authenticated ? ( ) : null}
) : null}