render articles

This commit is contained in:
2026-08-22 17:57:54 -05:00
parent 1073b794d6
commit 6f21248132
25 changed files with 991 additions and 433 deletions
@@ -0,0 +1,70 @@
import type { ArticleBlock } from "../types/ArticleBlock";
import { TitleBlock, ParagraphBlock, HeadingBlock, ImageBlock, CodeBlock, QuoteBlock, ListBlock } from "./blocks/";
type ArticleContentProps = {
blocks: ArticleBlock[];
};
export function ArticleContent({ blocks }: ArticleContentProps) {
const sortedBlocks = [...blocks].sort(
(a, b) => a.position - b.position
);
return (
<main className="mx-auto max-w-4xl">
<article className="prose prose-lg max-w-none">
{sortedBlocks.map((block) => (
<ArticleBlockRenderer
key={block.id}
block={block}
/>
))}
</article>
</main>
);
}
function ArticleBlockRenderer({
block,
}: {
block: ArticleBlock;
}) {
switch (block.type) {
case "title":
return <TitleBlock content={block.content} />;
case "paragraph":
return <ParagraphBlock content={block.content} />;
case "heading":
return <HeadingBlock content={block.content} />;
case "image":
return <ImageBlock content={block.content} />;
case "code":
return <CodeBlock content={block.content} />;
case "quote":
return <QuoteBlock content={block.content} />;
case "ordered_list":
return (
<ListBlock
content={block.content}
ordered
/>
);
case "unordered_list":
return (
<ListBlock
content={block.content}
ordered={false}
/>
);
default:
return <div>UNHANDLED BLOCK TYPE</div>;
}
};
+76
View File
@@ -0,0 +1,76 @@
import type { Article } from "./../types/Article.ts";
import type { User } from "./../types/User.ts";
type ArticleHeaderProps = {
article: Article;
author: User;
};
export function ArticleHeader({ article, author }: ArticleHeaderProps) {
const publishedDate = article.date_published
? new Date(article.date_published)
: null;
return (
<header className="mx-auto mb-12 max-w-4xl border-b-2 border-bold-2 pb-8">
{/* Status */}
<div className="mb-4 flex items-center gap-3 text-sm uppercase tracking-widest">
<span className="bg-bold-2 px-3 py-1 font-bold text-tb2">
{article.article_status}
</span>
{publishedDate && (
<time
dateTime={article.date_published ?? undefined}
className="text-tn1"
>
{publishedDate.toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
</div>
{/* Title */}
<h1 className="mb-6 text-4xl font-bold leading-tight text-tb1 md:text-5xl">
{article.name}
</h1>
{/* Summary */}
{article.summary && (
<p className="mb-6 max-w-3xl text-xl leading-relaxed text-tn1">
{article.summary}
</p>
)}
{/* Author / metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-tn1">
<span>
By{" "}
<span className="font-bold text-tb1">
{author.display_name}
</span>
</span>
<span aria-hidden="true">·</span>
<time dateTime={article.date_created}>
Created{" "}
{new Date(article.date_created).toLocaleDateString()}
</time>
{article.last_edit !== article.date_created && (
<>
<span aria-hidden="true">·</span>
<time dateTime={article.last_edit}>
Updated {new Date(article.last_edit).toLocaleDateString()}
</time>
</>
)}
</div>
</header>
);
}
@@ -0,0 +1,22 @@
export function getString(
content: Record<string, unknown>,
key: string
): string {
const value = content[key];
return typeof value === "string" ? value : "";
}
export function getStringArray(
content: Record<string, unknown>,
key: string
): string[] {
const value = content[key];
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(item): item is string => typeof item === "string"
);
}
@@ -0,0 +1,26 @@
import { getString } from "./BlockHelpers";
export default function CodeBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const code = getString(content, "code");
const language = getString(content, "language");
if (!code) return null;
return (
<div className="my-8 overflow-hidden border-2 border-dark bg-dark">
{language && (
<div className="border-b border-neutral-600 px-4 py-2 text-xs uppercase tracking-widest text-neutral-300">
{language}
</div>
)}
<pre className="overflow-x-auto p-5 font-mono text-sm leading-6 text-tb2">
<code>{code}</code>
</pre>
</div>
);
}
@@ -0,0 +1,26 @@
import { getString } from "./BlockHelpers";
export default function HeadingBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const text = getString(content, "text");
const level = getString(content, "level");
if (!text) return null;
if (level === "3") {
return (
<h3 className="mb-4 mt-10 text-2xl font-bold text-tb1">
{text}
</h3>
);
}
return (
<h2 className="mb-4 mt-10 text-3xl font-bold text-tb1">
{text}
</h2>
);
}
@@ -0,0 +1,29 @@
import { getString } from "./BlockHelpers";
export default function ImageBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const src = getString(content, "src");
const alt = getString(content, "alt");
const caption = getString(content, "caption");
if (!src) return null;
return (
<figure className="my-10">
<img
src={src}
alt={alt}
className="h-auto w-full border-2 border-dark object-cover"
/>
{caption && (
<figcaption className="mt-2 text-center text-sm text-tn1">
{caption}
</figcaption>
)}
</figure>
);
}
@@ -0,0 +1,28 @@
import { getStringArray } from "./BlockHelpers";
export default function ListBlock({
content,
ordered,
}: {
content: Record<string, unknown>;
ordered: boolean;
}) {
const items = getStringArray(content, "items");
if (items.length === 0) return null;
const Tag = ordered ? "ol" : "ul";
return (
<Tag
className={[
"mb-6 space-y-2 pl-8 text-lg leading-8 text-td",
ordered ? "list-decimal" : "list-disc",
].join(" ")}
>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</Tag>
);
}
@@ -0,0 +1,17 @@
import { getString } from "./BlockHelpers";
export default function ParagraphBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const text = getString(content, "text");
if (!text) return null;
return (
<p className="mb-6 text-lg leading-8 text-td">
{text}
</p>
);
}
@@ -0,0 +1,24 @@
import { getString } from "./BlockHelpers";
export default function QuoteBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const text = getString(content, "text");
const attribution = getString(content, "attribution");
if (!text) return null;
return (
<blockquote className="my-10 border-l-4 border-bold-1 bg-bold-1/20 px-6 py-4 text-xl italic leading-8 text-tb1">
<p className="m-0">{text}</p>
{attribution && (
<footer className="mt-3 text-sm not-italic text-tn1">
{attribution}
</footer>
)}
</blockquote>
);
}
@@ -0,0 +1,17 @@
import { getString } from "./BlockHelpers";
export default function TitleBlock({
content,
}: {
content: Record<string, unknown>;
}) {
const text = getString(content, "text");
if (!text) return null;
return (
<h2 className="mb-6 mt-12 text-3xl font-bold leading-tight text-tb1 md:text-4xl">
{text}
</h2>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { default as TitleBlock } from "./TitleBlock";
export { default as ParagraphBlock } from "./ParagraphBlock";
export { default as HeadingBlock } from "./HeadingBlock";
export { default as ImageBlock } from "./ImageBlock";
export { default as CodeBlock } from "./CodeBlock";
export { default as QuoteBlock } from "./QuoteBlock";
export { default as ListBlock } from "./ListBlock";
+74 -4
View File
@@ -1,12 +1,82 @@
import { useParams } from 'react-router';
// import { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import type { Article } from '../types/Article.ts';
import { ArticleSchema } from '../types/Article.ts';
import type { ArticleBlock } from '../types/ArticleBlock';
import { ArticleBlockListSchema } from '../types/ArticleBlock';
import { UserSchema } from '../types/User';
import type { User } from '../types/User';
import { ArticleHeader } from '../components/ArticleHeader.tsx';
import { ArticleContent } from '../components/ArticleContent.tsx';
export default function Article() {
let { slug } = useParams();
const [article, setArticle] = useState<Article>();
const [articleBlocks, setBlocks] = useState<ArticleBlock[]>();
const [author, setAuthor] = useState<User>();
useEffect(() => {
getArticle(slug!, setArticle, setBlocks, setAuthor);
}, []);
return (
<div id="article" className="page-container">
<h1>Article: {slug}</h1>
</div>
article === undefined || author === undefined || articleBlocks === undefined ?
<div id="article" className="page-container">
<h1>LOADING</h1>
</div> :
<div id="article" className="page-container">
<ArticleHeader article={article} author={author} />
<ArticleContent blocks={articleBlocks} />
</div>
);
};
async function getArticle(
articleSlug: String,
setArticle: React.Dispatch<React.SetStateAction<Article | undefined>>,
setBlocks: React.Dispatch<React.SetStateAction<ArticleBlock[] | undefined>>,
setAuthor: React.Dispatch<React.SetStateAction<User | undefined>>
) {
const result = await fetch(
`/api/articles/getArticleBySlug/${articleSlug}`,
{
method: "GET",
headers: { "Accept": "application/json" }
}
);
const json = await result.json();
const data = ArticleSchema.parse(json);
setArticle(data);
getBlocks(data.id, setBlocks);
getAuthor(data.author_id, setAuthor);
};
async function getBlocks(articleId: String, setBlocks: React.Dispatch<React.SetStateAction<ArticleBlock[] | undefined>>) {
const result = await fetch(
`/api/blocks/getBlocks/${articleId}`,
{
method: "GET",
headers: { "Accept": "application/json" }
}
);
const json = await result.json();
const data = ArticleBlockListSchema.parse(json);
setBlocks(data);
};
async function getAuthor(userId: String, setBlocks: React.Dispatch<React.SetStateAction<User | undefined>>) {
const result = await fetch(
`/api/users/getUserName/${userId}`,
{
method: "GET",
headers: { "Accept": "application/json" }
}
);
const json = await result.json();
const data = UserSchema.parse(json);
setBlocks(data);
};
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
// import { Button } from '@mui/material';
import { Button } from '@mui/material';
import type { Article } from '../types/Article.ts';
import { ArticleListSchema } from '../types/Article.ts';
import ArticleCard from '../components/ArticleCard.tsx';
@@ -27,12 +27,12 @@ export default function ArticleList() {
return (
<div className="page-container p-3 flex flex-col gap-5">
{/*<Button
{<Button
style={{ border: "solid", borderWidth: "1px" }}
onClick={() => getAllArticles()}
>
Refresh
</Button>*/}
</Button>}
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(16rem,1fr))] gap-5">
{articles.map((article) => (<ArticleCard key={article.id} article={article} />))}
</div>
+5 -1
View File
@@ -1,15 +1,19 @@
import { z } from 'zod';
export const ArticleStatusSchema = z.enum(["draft", "published", "archived"]);
export const ArticleSchema = z.object({
id: z.string(),
name: z.string(),
article_status: z.enum(["draft", "published", "archived"]),
article_status: ArticleStatusSchema,
author_id: z.string(),
slug: z.string(),
summary: z.string(),
date_created: z.string(),
last_edit: z.string(),
date_published: z.string().nullable(),
previous_article: z.string().nullable(),
next_article: z.string().nullable(),
});
export const ArticleListSchema = z.array(ArticleSchema);
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
export const BlockTypeSchema = z.enum([
"title",
"paragraph",
"heading",
"image",
"code",
"quote",
"ordered_list",
"unordered_list",
]);
export const ArticleBlockSchema = z.object({
id: z.string(),
article_id: z.string(),
position: z.number().int(),
type: BlockTypeSchema,
content: z.record(z.string(), z.unknown()),
});
export const ArticleBlockListSchema = z.array(ArticleBlockSchema);
export type ArticleBlock = z.infer<typeof ArticleBlockSchema>;
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const ImageSchema = z.object({
id: z.string(),
bucket: z.string(),
key: z.string(),
mime_type: z.string(),
height: z.number().int(),
width: z.number().int(),
bytes: z.number().int(),
});
export const ImageListSchema = z.array(ImageSchema);
export type Image = z.infer<typeof ImageSchema>;
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
// we don't want things like refresh token or hashed password to ever
// propogate to the frontend, so we don't include them in the schema.
// enforce in the backend.
export const UserSchema = z.object({
id: z.string(),
display_name: z.string(),
});
export const UserListSchema = z.array(UserSchema);
export type User = z.infer<typeof UserSchema>;