Initial Robot U site prototype
This commit is contained in:
commit
fe19f200d7
27 changed files with 3677 additions and 0 deletions
674
frontend/src/App.tsx
Normal file
674
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
296
frontend/src/MarkdownContent.tsx
Normal file
296
frontend/src/MarkdownContent.tsx
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
420
frontend/src/index.css
Normal 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
12
frontend/src/main.tsx
Normal 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
90
frontend/src/types.ts
Normal 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[];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue