render articles
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user