Initial Robot U site prototype

This commit is contained in:
Kacper 2026-04-08 06:03:48 -04:00
commit fe19f200d7
27 changed files with 3677 additions and 0 deletions

674
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,674 @@
import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
CourseCard,
CourseChapter,
CourseLesson,
DiscussionCard,
DiscussionReply,
EventCard,
PostCard,
PrototypeData,
} from "./types";
function formatTimestamp(value: string): string {
if (!value) {
return "Unknown time";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "Unknown time";
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function normalizePathname(pathname: string): string {
if (!pathname || pathname === "/") {
return "/";
}
return pathname.replace(/\/+$/, "") || "/";
}
function parseDiscussionRoute(pathname: string): number | null {
const match = normalizePathname(pathname).match(/^\/discussions\/(\d+)$/);
if (!match) {
return null;
}
const issueId = Number(match[1]);
return Number.isFinite(issueId) ? issueId : null;
}
function parseCourseRoute(pathname: string): { owner: string; repo: string } | null {
const match = normalizePathname(pathname).match(/^\/courses\/([^/]+)\/([^/]+)$/);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
};
}
function parseLessonRoute(pathname: string): {
owner: string;
repo: string;
chapter: string;
lesson: string;
} | null {
const match = normalizePathname(pathname).match(
/^\/courses\/([^/]+)\/([^/]+)\/lessons\/([^/]+)\/([^/]+)$/,
);
if (!match) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
chapter: decodeURIComponent(match[3]),
lesson: decodeURIComponent(match[4]),
};
}
function normalizeRouteKey(value: string): string {
return decodeURIComponent(value).trim().toLowerCase();
}
function findCourseByRoute(
courses: CourseCard[],
route: { owner: string; repo: string },
): CourseCard | undefined {
const routeOwner = normalizeRouteKey(route.owner);
const routeRepo = normalizeRouteKey(route.repo);
const routeFullName = `${routeOwner}/${routeRepo}`;
return courses.find((course) => {
const [repoOwner = "", repoName = ""] = course.repo.split("/", 2);
const courseOwner = normalizeRouteKey(course.owner || repoOwner);
const courseName = normalizeRouteKey(course.name || repoName);
const courseFullName = normalizeRouteKey(course.repo);
return (
(courseOwner === routeOwner && courseName === routeRepo) ||
courseFullName === routeFullName ||
courseName === routeRepo
);
});
}
function findLessonByRoute(
course: CourseCard,
route: { chapter: string; lesson: string },
): { chapter: CourseChapter; lesson: CourseLesson } | undefined {
const routeChapter = normalizeRouteKey(route.chapter);
const routeLesson = normalizeRouteKey(route.lesson);
for (const chapter of course.outline) {
if (normalizeRouteKey(chapter.slug) !== routeChapter) {
continue;
}
const lesson = chapter.lessons.find((entry) => normalizeRouteKey(entry.slug) === routeLesson);
if (lesson) {
return { chapter, lesson };
}
}
return undefined;
}
function usePathname() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
useEffect(() => {
function handlePopState() {
setPathname(normalizePathname(window.location.pathname));
}
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
}, []);
function navigate(nextPath: string) {
const normalized = normalizePathname(nextPath);
if (normalized === pathname) {
return;
}
window.history.pushState({}, "", normalized);
window.scrollTo({ top: 0, behavior: "auto" });
setPathname(normalized);
}
return { pathname, navigate };
}
function SectionHeader(props: { title: string }) {
return (
<header className="section-header">
<h2>{props.title}</h2>
</header>
);
}
function EmptyState(props: { copy: string }) {
return <p className="empty-state">{props.copy}</p>;
}
function CourseItem(props: { course: CourseCard; onOpenCourse: (course: CourseCard) => void }) {
const { course, onOpenCourse } = props;
return (
<button
type="button"
className="card course-card-button"
onClick={() => {
onOpenCourse(course);
}}
>
<h3>{course.title}</h3>
<p className="muted-copy">{course.summary}</p>
<p className="meta-line">
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
</p>
</button>
);
}
function PostItem(props: { post: PostCard }) {
const { post } = props;
return (
<article className="card">
<h3>{post.title}</h3>
<p className="muted-copy">{post.summary}</p>
<p className="meta-line">{post.repo}</p>
</article>
);
}
function EventItem(props: { event: EventCard }) {
const { event } = props;
return (
<article className="card">
<h3>{event.title}</h3>
<p className="muted-copy">{event.when}</p>
<p className="meta-line">{event.source}</p>
</article>
);
}
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void;
}) {
const { discussion, onOpenDiscussion } = props;
return (
<button
type="button"
className="discussion-preview-card"
onClick={() => {
onOpenDiscussion(discussion.id);
}}
>
<h3>{discussion.title}</h3>
<p className="meta-line">
{discussion.repo} · {discussion.author} · {formatTimestamp(discussion.updated_at)} ·{" "}
{discussion.replies} replies
</p>
</button>
);
}
function DiscussionReplyCard(props: { reply: DiscussionReply }) {
const { reply } = props;
return (
<article className="reply-card">
<p className="reply-author">{reply.author}</p>
<p className="meta-line">{formatTimestamp(reply.created_at)}</p>
<MarkdownContent markdown={reply.body} className="thread-copy" />
</article>
);
}
function ComposeBox() {
return (
<form
className="compose-box"
onSubmit={(event) => {
event.preventDefault();
}}
>
<textarea className="compose-input" placeholder="Write a reply" />
<div className="compose-actions">
<button type="submit" className="compose-button" disabled>
Post reply
</button>
</div>
</form>
);
}
function CoursePage(props: {
course: CourseCard;
onGoHome: () => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
}) {
const { course, onGoHome, onOpenLesson } = props;
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to courses
</button>
<article className="panel">
<header className="thread-header">
<h1>{course.title}</h1>
<p className="meta-line">
{course.repo} · {course.lessons} lessons · {course.chapters} chapters
</p>
</header>
<p className="muted-copy">{course.summary}</p>
</article>
<article className="panel">
<header className="subsection-header">
<h2>Course outline</h2>
</header>
{course.outline.length > 0 ? (
<div className="outline-list">
{course.outline.map((chapter) => (
<section key={chapter.slug} className="outline-chapter">
<h3>{chapter.title}</h3>
<div className="lesson-list">
{chapter.lessons.map((lesson, index) => (
<button
key={lesson.path}
type="button"
className="lesson-row lesson-row-button"
onClick={() => {
onOpenLesson(course, chapter, lesson);
}}
>
<p className="lesson-index">{index + 1 < 10 ? `0${index + 1}` : index + 1}</p>
<div>
<p className="lesson-title">{lesson.title}</p>
{lesson.summary ? <p className="lesson-summary">{lesson.summary}</p> : null}
<p className="meta-line">{lesson.file_path || lesson.path}</p>
</div>
</button>
))}
</div>
</section>
))}
</div>
) : (
<EmptyState copy="This course repo has no lesson folders yet." />
)}
</article>
</section>
);
}
function LessonPage(props: {
course: CourseCard;
chapter: CourseChapter;
lesson: CourseLesson;
onGoCourse: () => void;
}) {
const { course, chapter, lesson, onGoCourse } = props;
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoCourse}>
Back to course
</button>
<article className="panel">
<header className="thread-header">
<h1>{lesson.title}</h1>
<p className="meta-line">
{course.repo} · {chapter.title}
</p>
</header>
{lesson.summary ? <p className="muted-copy">{lesson.summary}</p> : null}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Lesson</h2>
</header>
{lessonBody ? (
<MarkdownContent markdown={lessonBody} className="lesson-body" />
) : (
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
)}
</article>
</section>
);
}
function DiscussionPage(props: { discussion: DiscussionCard; onGoHome: () => void }) {
const { discussion, onGoHome } = props;
return (
<section className="thread-view">
<button type="button" className="back-link" onClick={onGoHome}>
Back to discussions
</button>
<article className="panel">
<header className="thread-header">
<h1>{discussion.title}</h1>
<p className="meta-line">
{discussion.repo} · Issue #{discussion.number} · {discussion.author} ·{" "}
{formatTimestamp(discussion.updated_at)}
</p>
</header>
<MarkdownContent markdown={discussion.body} className="thread-copy" />
</article>
<article className="panel">
<header className="subsection-header">
<h2>Replies</h2>
<p className="meta-line">{discussion.comments.length}</p>
</header>
{discussion.comments.length > 0 ? (
<div className="reply-list">
{discussion.comments.map((reply) => (
<DiscussionReplyCard key={reply.id} reply={reply} />
))}
</div>
) : (
<EmptyState copy="No replies yet." />
)}
</article>
<article className="panel">
<header className="subsection-header">
<h2>Reply</h2>
</header>
<ComposeBox />
</article>
</section>
);
}
function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenDiscussion: (id: number) => void;
}) {
const { data, onOpenCourse, onOpenDiscussion } = props;
return (
<>
<section className="page-header">
<p className="page-kicker">Robot U</p>
<h1>Courses, projects, and discussions.</h1>
<p className="muted-copy">
A single place for lessons, member work, and community conversation.
</p>
</section>
<section className="page-section">
<SectionHeader title="Courses" />
{data.featured_courses.length > 0 ? (
<div className="card-grid">
{data.featured_courses.map((course) => (
<CourseItem key={course.repo} course={course} onOpenCourse={onOpenCourse} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/lessons/` were found." />
)}
</section>
<section className="two-column-grid">
<div className="page-section">
<SectionHeader title="Posts" />
{data.recent_posts.length > 0 ? (
<div className="stack">
{data.recent_posts.map((post) => (
<PostItem key={`${post.repo}:${post.title}`} post={post} />
))}
</div>
) : (
<EmptyState copy="No public repos with `/blogs/` were found." />
)}
</div>
<div className="page-section">
<SectionHeader title="Events" />
{data.upcoming_events.length > 0 ? (
<div className="stack">
{data.upcoming_events.map((event) => (
<EventItem key={`${event.source}:${event.title}`} event={event} />
))}
</div>
) : (
<EmptyState copy="ICS feeds are not configured yet." />
)}
</div>
</section>
<section className="page-section">
<SectionHeader title="Discussions" />
{data.recent_discussions.length > 0 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No visible Forgejo issues were returned for this account." />
)}
</section>
</>
);
}
function AppContent(props: {
data: PrototypeData;
pathname: string;
onOpenCourse: (course: CourseCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onGoHome: () => void;
}) {
const lessonRoute = parseLessonRoute(props.pathname);
if (lessonRoute !== null) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, lessonRoute);
if (!selectedCourse) {
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to courses
</button>
</section>
);
}
const selectedLesson = findLessonByRoute(selectedCourse, lessonRoute);
if (!selectedLesson) {
return (
<section className="page-message">
<h1>Lesson not found.</h1>
<button
type="button"
className="back-link"
onClick={() => {
props.onOpenCourse(selectedCourse);
}}
>
Back to course
</button>
</section>
);
}
return (
<LessonPage
course={selectedCourse}
chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson}
onGoCourse={() => {
props.onOpenCourse(selectedCourse);
}}
/>
);
}
const courseRoute = parseCourseRoute(props.pathname);
if (courseRoute !== null) {
const selectedCourse = findCourseByRoute(props.data.featured_courses, courseRoute);
if (!selectedCourse) {
return (
<section className="page-message">
<h1>Course not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to courses
</button>
</section>
);
}
return (
<CoursePage
course={selectedCourse}
onGoHome={props.onGoHome}
onOpenLesson={props.onOpenLesson}
/>
);
}
const discussionId = parseDiscussionRoute(props.pathname);
if (discussionId === null) {
return (
<HomeView
data={props.data}
onOpenCourse={props.onOpenCourse}
onOpenDiscussion={props.onOpenDiscussion}
/>
);
}
const selectedDiscussion = props.data.recent_discussions.find(
(discussion) => discussion.id === discussionId,
);
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Discussion not found.</h1>
<button type="button" className="back-link" onClick={props.onGoHome}>
Back to discussions
</button>
</section>
);
}
return <DiscussionPage discussion={selectedDiscussion} onGoHome={props.onGoHome} />;
}
export default function App() {
const [data, setData] = useState<PrototypeData | null>(null);
const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
useEffect(() => {
const controller = new AbortController();
async function loadPrototype() {
try {
const response = await fetch("/api/prototype", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Prototype request failed with ${response.status}`);
}
const payload = (await response.json()) as PrototypeData;
setData(payload);
} catch (loadError) {
if (controller.signal.aborted) {
return;
}
const message =
loadError instanceof Error ? loadError.message : "Unknown prototype loading error";
setError(message);
}
}
loadPrototype();
return () => {
controller.abort();
};
}, []);
function openDiscussion(id: number) {
navigate(`/discussions/${id}`);
}
function openCourse(course: CourseCard) {
navigate(`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}`);
}
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
}
function goHome() {
navigate("/");
}
return (
<main className="app-shell">
{error ? (
<section className="page-message">
<h1>Backend data did not load.</h1>
<p className="muted-copy">{error}</p>
</section>
) : data ? (
<AppContent
data={data}
pathname={pathname}
onOpenCourse={openCourse}
onOpenLesson={openLesson}
onOpenDiscussion={openDiscussion}
onGoHome={goHome}
/>
) : (
<section className="page-message">
<h1>Loading content.</h1>
</section>
)}
</main>
);
}

View file

@ -0,0 +1,296 @@
type ListType = "ol" | "ul";
type ParserState = {
output: string[];
paragraphLines: string[];
blockquoteLines: string[];
listItems: string[];
listType: ListType | null;
inCodeBlock: boolean;
codeLanguage: string;
codeLines: string[];
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function normalizeLinkTarget(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("/")) {
return escapeHtml(trimmed);
}
try {
const url = new URL(trimmed);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
} catch {
return null;
}
return null;
}
function renderInline(markdown: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
rendered = rendered.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `__CODE_TOKEN_${codeTokens.length}__`;
codeTokens.push(`<code>${code}</code>`);
return token;
});
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href);
if (!safeHref) {
return label;
}
return `<a href="${safeHref}" target="_blank" rel="noreferrer">${label}</a>`;
},
);
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/__CODE_TOKEN_(\d+)__/g, (_match, index: string) => {
return codeTokens[Number(index)] ?? "";
});
}
function createParserState(): ParserState {
return {
output: [],
paragraphLines: [],
blockquoteLines: [],
listItems: [],
listType: null,
inCodeBlock: false,
codeLanguage: "",
codeLines: [],
};
}
function flushParagraph(state: ParserState) {
if (state.paragraphLines.length === 0) {
return;
}
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`);
state.paragraphLines.length = 0;
}
function flushList(state: ParserState) {
if (state.listItems.length === 0 || !state.listType) {
return;
}
state.output.push(
`<${state.listType}>${state.listItems.map((item) => `<li>${item}</li>`).join("")}</${state.listType}>`,
);
state.listItems.length = 0;
state.listType = null;
}
function flushBlockquote(state: ParserState) {
if (state.blockquoteLines.length === 0) {
return;
}
state.output.push(
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`,
);
state.blockquoteLines.length = 0;
}
function flushCodeBlock(state: ParserState) {
if (!state.inCodeBlock) {
return;
}
const languageClass = state.codeLanguage
? ` class="language-${escapeHtml(state.codeLanguage)}"`
: "";
state.output.push(
`<pre><code${languageClass}>${escapeHtml(state.codeLines.join("\n"))}</code></pre>`,
);
state.inCodeBlock = false;
state.codeLanguage = "";
state.codeLines.length = 0;
}
function flushInlineBlocks(state: ParserState) {
flushParagraph(state);
flushList(state);
flushBlockquote(state);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
if (!state.inCodeBlock) {
return false;
}
if (line.trim().startsWith("```")) {
flushCodeBlock(state);
} else {
state.codeLines.push(line);
}
return true;
}
function handleFenceStart(state: ParserState, line: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
flushInlineBlocks(state);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
function handleBlankLine(state: ParserState, line: string): boolean {
if (line.trim()) {
return false;
}
flushInlineBlocks(state);
return true;
}
function handleHeadingLine(state: ParserState, line: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
flushInlineBlocks(state);
const level = headingMatch[1].length;
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`);
return true;
}
function handleRuleLine(state: ParserState, line: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false;
}
flushInlineBlocks(state);
state.output.push("<hr />");
return true;
}
function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern);
if (!match) {
return false;
}
flushParagraph(state);
flushBlockquote(state);
if (state.listType !== listType) {
flushList(state);
state.listType = listType;
}
state.listItems.push(renderInline(match[1].trim()));
return true;
}
function handleBlockquoteLine(state: ParserState, line: string): boolean {
const match = line.match(/^>\s?(.*)$/);
if (!match) {
return false;
}
flushParagraph(state);
flushList(state);
state.blockquoteLines.push(match[1].trim());
return true;
}
function handleParagraphLine(state: ParserState, line: string) {
flushList(state);
flushBlockquote(state);
state.paragraphLines.push(line.trim());
}
function processMarkdownLine(state: ParserState, line: string) {
if (handleCodeBlockLine(state, line)) {
return;
}
if (handleFenceStart(state, line)) {
return;
}
if (handleBlankLine(state, line)) {
return;
}
if (handleHeadingLine(state, line)) {
return;
}
if (handleRuleLine(state, line)) {
return;
}
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
return;
}
if (handleBlockquoteLine(state, line)) {
return;
}
handleParagraphLine(state, line);
}
function markdownToHtml(markdown: string): string {
const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) {
processMarkdownLine(state, line);
}
flushInlineBlocks(state);
flushCodeBlock(state);
return state.output.join("");
}
export function stripLeadingTitleHeading(markdown: string, title: string): string {
const trimmed = markdown.trimStart();
if (!trimmed.startsWith("#")) {
return markdown;
}
const lines = trimmed.split("\n");
const firstLine = lines[0]?.trim() ?? "";
if (firstLine === `# ${title}`) {
return lines.slice(1).join("\n").trimStart();
}
return markdown;
}
export function MarkdownContent(props: { markdown: string; className?: string }) {
const html = markdownToHtml(props.markdown);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}

420
frontend/src/index.css Normal file
View file

@ -0,0 +1,420 @@
:root {
color-scheme: dark;
font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
--bg: #0d1117;
--panel: #151b23;
--panel-hover: #1a2230;
--border: #2b3442;
--text: #edf2f7;
--muted: #9aa6b2;
--accent: #84d7ff;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
}
body {
min-width: 20rem;
}
a {
color: inherit;
}
h1,
h2,
h3,
p {
margin: 0;
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
width: min(72rem, 100%);
margin: 0 auto;
padding: 2rem 1rem 3rem;
}
.page-header,
.page-section,
.panel,
.page-message {
border: 0.0625rem solid var(--border);
background: var(--panel);
border-radius: 0.9rem;
}
.page-header,
.page-section,
.page-message {
padding: 1.25rem;
}
.page-header {
margin-bottom: 1rem;
}
.page-header h1,
.thread-header h1,
.page-message h1 {
font-size: clamp(1.6rem, 3vw, 2.4rem);
line-height: 1.1;
}
.page-kicker {
margin-bottom: 0.35rem;
color: var(--accent);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.page-section {
margin-bottom: 1rem;
}
.section-header {
margin-bottom: 0.9rem;
}
.section-header h2,
.subsection-header h2 {
font-size: 1.1rem;
}
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.card-grid,
.stack,
.reply-list {
display: grid;
gap: 0.75rem;
}
.card-grid,
.two-column-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.card,
.discussion-preview-card,
.reply-card {
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
background: #111722;
}
.card,
.reply-card {
padding: 1rem;
}
.course-card-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
}
.course-card-button:hover,
.course-card-button:focus-visible {
background: var(--panel-hover);
outline: none;
}
.card h3,
.discussion-preview-card h3 {
margin-bottom: 0.35rem;
font-size: 1rem;
}
.discussion-preview-card {
width: 100%;
padding: 1rem;
cursor: pointer;
text-align: left;
color: inherit;
}
.discussion-preview-card:hover,
.discussion-preview-card:focus-visible {
background: var(--panel-hover);
outline: none;
}
.muted-copy,
.meta-line,
.empty-state {
color: var(--muted);
}
.muted-copy {
max-width: 44rem;
}
.meta-line {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.empty-state {
padding: 0.25rem 0;
}
.back-link,
.secondary-link,
.compose-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.65rem;
border: 0.0625rem solid var(--border);
padding: 0.65rem 0.85rem;
text-decoration: none;
}
.back-link {
margin-bottom: 1rem;
background: transparent;
color: var(--text);
cursor: pointer;
}
.thread-view {
display: grid;
gap: 1rem;
}
.panel {
padding: 1.25rem;
}
.thread-header {
margin-bottom: 1rem;
}
.panel-actions {
margin-top: 1rem;
}
.thread-copy {
display: grid;
gap: 0.85rem;
}
.reply-author {
font-weight: 600;
}
.outline-list,
.lesson-list {
display: grid;
gap: 1rem;
}
.outline-chapter h3 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.lesson-row {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
gap: 0.75rem;
align-items: start;
border-top: 0.0625rem solid var(--border);
padding-top: 0.75rem;
}
.lesson-row-button {
width: 100%;
cursor: pointer;
text-align: left;
color: inherit;
background: transparent;
}
.lesson-row-button:hover,
.lesson-row-button:focus-visible {
background: var(--panel-hover);
outline: none;
border-radius: 0.5rem;
}
.lesson-row:first-child {
border-top: 0;
padding-top: 0;
}
.lesson-index {
color: var(--muted);
font-size: 0.9rem;
}
.lesson-title {
font-weight: 600;
}
.lesson-summary {
margin-top: 0.35rem;
color: var(--muted);
}
.markdown-content {
display: grid;
gap: 0.85rem;
}
.markdown-content > :first-child {
margin-top: 0;
}
.markdown-content > :last-child {
margin-bottom: 0;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4 {
line-height: 1.15;
}
.markdown-content h1 {
font-size: 1.8rem;
}
.markdown-content h2 {
font-size: 1.35rem;
}
.markdown-content h3 {
font-size: 1.1rem;
}
.markdown-content p,
.markdown-content li,
.markdown-content blockquote {
color: var(--text);
}
.markdown-content ul,
.markdown-content ol {
margin: 0;
padding-left: 1.4rem;
}
.markdown-content li + li {
margin-top: 0.35rem;
}
.markdown-content code {
border-radius: 0.35rem;
padding: 0.12rem 0.35rem;
background: #0b1017;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.92em;
}
.markdown-content pre {
overflow-x: auto;
margin: 0;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
background: #0b1017;
}
.markdown-content pre code {
padding: 0;
background: transparent;
}
.markdown-content blockquote {
margin: 0;
border-left: 0.2rem solid var(--accent);
padding-left: 0.9rem;
}
.markdown-content a {
color: var(--accent);
}
.markdown-content hr {
width: 100%;
height: 0.0625rem;
margin: 0;
border: 0;
background: var(--border);
}
.compose-box {
display: grid;
gap: 0.85rem;
}
.compose-input {
width: 100%;
min-height: 9rem;
resize: vertical;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem 0.95rem;
color: var(--text);
background: #0f141d;
}
.compose-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.compose-button {
color: #7a8696;
background: #101722;
cursor: not-allowed;
}
.secondary-link {
color: var(--accent);
}
@media (max-width: 48rem) {
.card-grid,
.two-column-grid {
grid-template-columns: 1fr;
}
.compose-actions,
.subsection-header {
align-items: flex-start;
flex-direction: column;
}
}

12
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,12 @@
import { render } from "preact";
import App from "./App";
import "./index.css";
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("App root element was not found.");
}
render(<App />, rootElement);

90
frontend/src/types.ts Normal file
View file

@ -0,0 +1,90 @@
export interface HeroData {
eyebrow: string;
title: string;
summary: string;
highlights: string[];
}
export interface SourceOfTruthCard {
title: string;
description: string;
}
export interface CourseCard {
title: string;
owner: string;
name: string;
repo: string;
html_url: string;
lessons: number;
chapters: number;
summary: string;
status: string;
outline: CourseChapter[];
}
export interface CourseChapter {
slug: string;
title: string;
lessons: CourseLesson[];
}
export interface CourseLesson {
slug: string;
title: string;
path: string;
file_path: string;
html_url: string;
summary: string;
body: string;
}
export interface PostCard {
title: string;
repo: string;
kind: string;
summary: string;
}
export interface EventCard {
title: string;
when: string;
source: string;
mode: string;
}
export interface DiscussionCard {
id: number;
title: string;
repo: string;
replies: number;
context: string;
author: string;
author_avatar_url: string;
state: string;
body: string;
number: number;
updated_at: string;
html_url: string;
labels: string[];
comments: DiscussionReply[];
}
export interface DiscussionReply {
id: number;
author: string;
avatar_url: string;
body: string;
created_at: string;
html_url: string;
}
export interface PrototypeData {
hero: HeroData;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];
upcoming_events: EventCard[];
recent_discussions: DiscussionCard[];
implementation_notes: string[];
}