1377 lines
38 KiB
TypeScript
1377 lines
38 KiB
TypeScript
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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; onOpenPost: (post: PostCard) => void }) {
|
|
const { post, onOpenPost } = props;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="card post-card-button"
|
|
onClick={() => {
|
|
onOpenPost(post);
|
|
}}
|
|
>
|
|
<h3>{post.title}</h3>
|
|
<p className="muted-copy">{post.summary}</p>
|
|
<p className="meta-line">
|
|
{post.repo} · {post.file_path || post.path}
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}}
|
|
/>
|
|
<div className="compose-actions">
|
|
<button
|
|
type="submit"
|
|
className="compose-button"
|
|
disabled={!canReply || !trimmedBody || isSubmitting}
|
|
>
|
|
{isSubmitting ? "Posting..." : "Post reply"}
|
|
</button>
|
|
</div>
|
|
{error ? <p className="compose-error">{error}</p> : null}
|
|
</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 PostPage(props: { post: PostCard }) {
|
|
const { post } = props;
|
|
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;
|
|
|
|
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}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function HomeView(props: {
|
|
data: PrototypeData;
|
|
onOpenCourse: (course: CourseCard) => void;
|
|
onOpenPost: (post: PostCard) => void;
|
|
onOpenDiscussion: (id: number) => void;
|
|
}) {
|
|
const { data, onOpenCourse, onOpenPost, 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>
|
|
|
|
<CoursesView data={data} onOpenCourse={onOpenCourse} />
|
|
|
|
<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.slug}`} post={post} onOpenPost={onOpenPost} />
|
|
))}
|
|
</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} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<button
|
|
type="button"
|
|
className="compose-button"
|
|
disabled={!auth.oauth_configured}
|
|
onClick={startForgejoSignIn}
|
|
>
|
|
Continue with Forgejo
|
|
</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)}`;
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
className="activity-card activity-card-button"
|
|
onClick={() => {
|
|
if (entry.route) {
|
|
onOpenRoute(entry.route);
|
|
}
|
|
}}
|
|
>
|
|
<h3>{entry.title}</h3>
|
|
<p className="muted-copy">{entry.detail}</p>
|
|
<p className="meta-line">{formatTimestamp(entry.timestamp)}</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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;
|
|
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 (
|
|
<section className="page-message">
|
|
<h1>Post not found.</h1>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return <PostPage post={selectedPost} />;
|
|
}
|
|
|
|
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 (
|
|
<section className="page-message">
|
|
<h1>Course not found.</h1>
|
|
<button type="button" className="back-link" onClick={props.onGoCourses}>
|
|
Back to courses
|
|
</button>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const selectedLesson = findLessonByRoute(selectedCourse, props.route);
|
|
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);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<CoursePage
|
|
course={selectedCourse}
|
|
onGoHome={props.onGoCourses}
|
|
onOpenLesson={props.onOpenLesson}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DiscussionRouteView(props: AppContentProps & { discussionId: number }) {
|
|
const selectedDiscussion = props.data.recent_discussions.find(
|
|
(discussion) => discussion.id === props.discussionId,
|
|
);
|
|
if (!selectedDiscussion) {
|
|
return (
|
|
<section className="page-message">
|
|
<h1>Discussion not found.</h1>
|
|
<button type="button" className="back-link" onClick={props.onGoDiscussions}>
|
|
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)) {
|
|
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} />;
|
|
}
|
|
|
|
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}
|
|
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}
|
|
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>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 <AppStatusPage title="Backend data did not load." copy={error} />;
|
|
}
|
|
|
|
if (!data) {
|
|
return <AppStatusPage title="Loading content." />;
|
|
}
|
|
|
|
return (
|
|
<LoadedApp
|
|
data={data}
|
|
pathname={pathname}
|
|
onOpenCourse={openCourse}
|
|
onOpenPost={openPost}
|
|
onOpenLesson={openLesson}
|
|
onOpenDiscussion={openDiscussion}
|
|
onOpenRoute={navigate}
|
|
onGoSignIn={goSignIn}
|
|
onReplyCreated={addReplyToDiscussion}
|
|
onGoHome={goHome}
|
|
onGoCourses={goCourses}
|
|
onGoDiscussions={goDiscussions}
|
|
onGoActivity={goActivity}
|
|
onSignOut={signOut}
|
|
/>
|
|
);
|
|
}
|