Archived
Restore the app files (prior commit mis-scoped its tar)
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">About</h1>
|
||||
|
||||
<div className="mb-10 rounded-lg border border-primary-200 bg-white p-8 shadow-sm">
|
||||
<h2 className="mb-4 text-2xl font-bold">Confessions of Grace</h2>
|
||||
<p className="mb-6 text-primary-700">
|
||||
Welcome to Confessions of Grace, a blog dedicated to exploring the riches of Reformed theology and the
|
||||
doctrines of grace. Our aim is to articulate timeless biblical truths in a clear, accessible manner,
|
||||
helping believers understand the depth and beauty of God's sovereign grace.
|
||||
</p>
|
||||
|
||||
<h2 className="mb-4 text-2xl font-bold">What Do You Mean By “Confessions of Grace”?</h2>
|
||||
<p className="mb-6 text-primary-700">
|
||||
The name “Confessions of Grace” is a play on Augustine's “Confessions” and John
|
||||
Bunyan's “Grace Abounding to the Chief of Sinners.” I originally wanted to call the blog
|
||||
“Confessions of the Chief of Sinners,” but that felt a bit too long. The idea behind those books
|
||||
is that we are all sinners saved by grace, needing to confess that grace to others. “Confessions of
|
||||
Grace” seems to fit that idea well.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 text-xl font-bold">Our Vision</h3>
|
||||
<p className="mb-6 text-primary-700">
|
||||
In an age of theological confusion and spiritual relativism, we seek to provide content that is firmly
|
||||
rooted in Scripture, historically informed, and pastorally sensitive. We believe that sound doctrine leads
|
||||
to doxology—that theology, properly understood, results in worship and wonder at the character and works
|
||||
of God.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 text-xl font-bold">What We Believe</h3>
|
||||
<p className="mb-6 text-primary-700">
|
||||
We stand in the tradition of the Protestant Reformation, affirming the five “solas”:
|
||||
</p>
|
||||
<ul className="mb-6 list-inside list-disc space-y-2 text-primary-700">
|
||||
<li><span className="font-semibold italic">Sola Scriptura</span> — Scripture Alone</li>
|
||||
<li><span className="font-semibold italic">Sola Fide</span> — Faith Alone</li>
|
||||
<li><span className="font-semibold italic">Sola Gratia</span> — Grace Alone</li>
|
||||
<li><span className="font-semibold italic">Solus Christus</span> — Christ Alone</li>
|
||||
<li><span className="font-semibold italic">Soli Deo Gloria</span> — Glory to God Alone</li>
|
||||
</ul>
|
||||
<p className="mb-6 text-primary-700">
|
||||
As Reformed Baptists, we affirm the doctrines of grace as articulated in the 1689 London Baptist Confession
|
||||
of Faith. This historic confession provides a robust and faithful summary of biblical doctrine, emphasizing
|
||||
God's sovereignty in salvation and the centrality of Christ in all things.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 text-xl font-bold">Our Associations</h3>
|
||||
<p className="mb-6 text-primary-700">
|
||||
We are associated with the{' '}
|
||||
<a href="https://reformedwitness.net" className="text-accent hover:text-accent-dark">
|
||||
Reformed Witness Network (RWN)
|
||||
</a>
|
||||
, a group committed to the proclamation of the gospel and the advancement of Christ's kingdom. Through
|
||||
RWN, we aim to foster fellowship among like-minded believers and support the spread of Reformed theology
|
||||
globally.
|
||||
</p>
|
||||
|
||||
<blockquote className="my-8 border-l-4 border-accent pl-4 italic text-primary-600">
|
||||
“For from him and through him and to him are all things. To him be glory forever. Amen.”
|
||||
<br />— Romans 11:36
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-primary-200 bg-white p-8 shadow-sm">
|
||||
<h2 className="mb-4 text-2xl font-bold">About the Author</h2>
|
||||
<div className="items-start gap-6 md:flex">
|
||||
<div className="md:w-2/3">
|
||||
<p className="mb-4 text-primary-700">
|
||||
My wife and I are members of Covenant Community Church, where I have been blessed to grow in faith and
|
||||
fellowship. My passion for theology and technology inspired me to start this blog as a way to share the
|
||||
beauty of God's sovereign grace. I hope to one day reach unreached people groups and share the
|
||||
gospel with them.
|
||||
</p>
|
||||
<p className="text-primary-700">
|
||||
Feel free to connect with me on X @auggie2lbcf or email me at contact@confessionsofgrace.com. I would
|
||||
love to hear from you and learn how I can serve you better.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { getAuthor } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
|
||||
export default function AuthorPage() {
|
||||
const { name = '' } = useParams();
|
||||
const { data: author, loading, error } = useAsync(() => getAuthor(name), [name]);
|
||||
|
||||
if (loading) return <p className="text-primary-500">Loading…</p>;
|
||||
if (error || !author) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-4 text-3xl font-bold">Author not found</h1>
|
||||
<p className="text-primary-700"><Link to="/authors">See all authors</Link>.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="card mb-10">
|
||||
<div className="flex items-center gap-6">
|
||||
{author.pfpLink && (
|
||||
<img src={author.pfpLink} alt={author.name} className="h-24 w-24 rounded-full object-cover" />
|
||||
)}
|
||||
<div>
|
||||
<h1 className="mb-1 text-3xl font-bold">{author.name}</h1>
|
||||
<p className="text-primary-500">
|
||||
{author.postCount} {author.postCount === 1 ? 'post' : 'posts'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{author.bio && <p className="mt-6 text-primary-700">{author.bio}</p>}
|
||||
<div className="mt-4 flex gap-4">
|
||||
{author.xLink && <a href={author.xLink} target="_blank" rel="noreferrer">X</a>}
|
||||
{author.fbLink && <a href={author.fbLink} target="_blank" rel="noreferrer">Facebook</a>}
|
||||
{author.instaLink && <a href={author.instaLink} target="_blank" rel="noreferrer">Instagram</a>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Posts</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{author.posts.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getAuthors } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
|
||||
export default function AuthorsPage() {
|
||||
const { data: authors, loading } = useAsync(() => getAuthors(), []);
|
||||
const list = authors ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Authors</h1>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.map((a) => (
|
||||
<div key={a.name} className="card">
|
||||
<div className="flex items-center gap-4">
|
||||
{a.pfpLink && <img src={a.pfpLink} alt={a.name} className="h-16 w-16 rounded-full object-cover" />}
|
||||
<div>
|
||||
<h2 className="mb-1 text-xl font-bold">
|
||||
<Link to={`/authors/${encodeURIComponent(a.name)}`} className="hover:text-accent-dark">{a.name}</Link>
|
||||
</h2>
|
||||
<p className="text-sm text-primary-500">
|
||||
{a.postCount} {a.postCount === 1 ? 'post' : 'posts'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{a.bio && <p className="mt-4 text-primary-700">{a.bio}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No authors yet.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface Chapter {
|
||||
title: string;
|
||||
paragraphs: Record<string, string>;
|
||||
}
|
||||
interface Confession {
|
||||
title: string;
|
||||
chapters: Record<string, Chapter>;
|
||||
}
|
||||
|
||||
// Served from public/ rather than bundled — it's a large static document.
|
||||
const loadConfession = async (): Promise<Confession> => {
|
||||
const res = await fetch('/data/1689-confession.json');
|
||||
if (!res.ok) throw new Error('could not load the confession');
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export default function ConfessionPage() {
|
||||
const { data, loading, error } = useAsync(loadConfession, []);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
if (loading) return <p className="text-primary-500">Loading the confession…</p>;
|
||||
if (error || !data) return <p className="text-primary-700">The confession could not be loaded.</p>;
|
||||
|
||||
const numbers = Object.keys(data.chapters).sort((a, b) => Number(a) - Number(b));
|
||||
const current = selected ?? numbers[0];
|
||||
const chapter = data.chapters[current];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">{data.title}</h1>
|
||||
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
<nav className="md:w-1/3">
|
||||
<div className="card max-h-[70vh] overflow-y-auto">
|
||||
<h2 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Chapters</h2>
|
||||
<ol className="space-y-2">
|
||||
{numbers.map((n) => (
|
||||
<li key={n}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(n)}
|
||||
className={cn(
|
||||
'w-full text-left text-sm transition-colors',
|
||||
current === n ? 'font-bold text-accent-dark' : 'text-primary-700 hover:text-accent-dark',
|
||||
)}
|
||||
>
|
||||
{n}. {data.chapters[n].title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<article className="md:w-2/3">
|
||||
<div className="card">
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">
|
||||
Chapter {current} — {chapter.title}
|
||||
</h2>
|
||||
<ol className="space-y-5">
|
||||
{Object.keys(chapter.paragraphs)
|
||||
.sort((a, b) => Number(a) - Number(b))
|
||||
.map((p) => (
|
||||
<li key={p} className="leading-relaxed text-primary-700">
|
||||
<span className="mr-2 font-bold text-accent-dark">{p}.</span>
|
||||
{chapter.paragraphs[p]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getPosts, getTags } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: posts, loading } = useAsync(() => getPosts(), []);
|
||||
const { data: tags } = useAsync(() => getTags(), []);
|
||||
const list = posts ?? [];
|
||||
const featured = list[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
<main className="md:w-2/3">
|
||||
{featured && (
|
||||
<div className="mb-12">
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow-md">
|
||||
<div className="md:flex">
|
||||
<div className="relative h-64 w-full bg-accent md:h-auto md:w-1/3 md:shrink-0">
|
||||
{featured.coverImage ? (
|
||||
<img src={featured.coverImage} alt={featured.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-accent text-6xl font-bold text-white">
|
||||
CG
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-accent">Latest Post</div>
|
||||
<Link
|
||||
to={`/posts/${featured.slug}`}
|
||||
className="mt-1 block text-2xl font-bold leading-tight text-primary-900 hover:text-accent-dark"
|
||||
>
|
||||
{featured.title}
|
||||
</Link>
|
||||
<p className="mt-2 text-primary-600">{featured.excerpt}</p>
|
||||
<div className="mt-4">
|
||||
<Link to={`/posts/${featured.slug}`} className="inline-flex items-center text-accent-dark hover:text-accent">
|
||||
Read more
|
||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Recent Posts</h2>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.slice(1).map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<Link to="/posts" className="button">View All Posts</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="md:w-1/3">
|
||||
<Sidebar recentPosts={list.slice(0, 5)} tags={tags ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h1 className="mb-4 text-3xl font-bold md:text-4xl">Page not found</h1>
|
||||
<p className="mb-8 text-primary-700">
|
||||
We couldn't find that page. Perhaps start from the <Link to="/">home page</Link> or browse the{' '}
|
||||
<Link to="/posts">archive</Link>.
|
||||
</p>
|
||||
<Link to="/" className="button">Back home</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { getPost } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import CommentSection from '../components/CommentSection';
|
||||
|
||||
export default function PostPage() {
|
||||
const { slug = '' } = useParams();
|
||||
const { data: post, loading, error } = useAsync(() => getPost(slug), [slug]);
|
||||
|
||||
if (loading) return <p className="text-primary-500">Loading…</p>;
|
||||
if (error || !post) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-4 text-3xl font-bold">Post not found</h1>
|
||||
<p className="text-primary-700">
|
||||
That post doesn't exist. <Link to="/posts">Browse the archive</Link>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl">
|
||||
{post.coverImage && (
|
||||
<div className="mb-8 h-72 w-full overflow-hidden rounded-md">
|
||||
<img src={post.coverImage} alt={post.title} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="mb-3 text-3xl font-bold md:text-4xl">{post.title}</h1>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-center text-sm text-primary-500">
|
||||
<Link to={`/authors/${encodeURIComponent(post.author)}`}>{post.author}</Link>
|
||||
<span className="mx-2">•</span>
|
||||
<time dateTime={post.date}>{format(new Date(post.date), 'MMMM d, yyyy')}</time>
|
||||
</div>
|
||||
|
||||
<div className="blog-post" dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-2 border-t border-primary-200 pt-6">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/tags/${encodeURIComponent(tag)}`}
|
||||
className="rounded-md bg-primary-100 px-2 py-1 text-sm text-primary-600 no-underline hover:bg-primary-200"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommentSection postId={post.slug} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getPosts } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
|
||||
export default function PostsPage() {
|
||||
const { data: posts, loading } = useAsync(() => getPosts(), []);
|
||||
const list = posts ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Archive</h1>
|
||||
<p className="mb-10 text-lg text-primary-700">Every post, newest first.</p>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
interface Resource {
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
link?: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const resources: Resource[] = [
|
||||
{
|
||||
title: 'The Second London Baptist Confession of Faith (1689)',
|
||||
author: 'Particular Baptists',
|
||||
description:
|
||||
'A historic Reformed Baptist confession of faith that aligns closely with the Westminster Confession but reflects Baptist distinctives.',
|
||||
link: '/confession',
|
||||
category: 'Confessions',
|
||||
},
|
||||
{
|
||||
title: 'The Heidelberg Catechism',
|
||||
author: 'Zacharias Ursinus & Caspar Olevianus',
|
||||
description: 'A warm, pastoral Reformed catechism organized around comfort in Christ.',
|
||||
link: 'https://www.ligonier.org/learn/articles/heidelberg-catechism',
|
||||
category: 'Confessions',
|
||||
},
|
||||
{
|
||||
title: 'Ligonier Ministries',
|
||||
author: 'Founded by R.C. Sproul',
|
||||
description:
|
||||
'A ministry dedicated to helping Christians know what they believe, why they believe it, how to live it, and how to share it.',
|
||||
link: 'https://www.ligonier.org/',
|
||||
category: 'Websites',
|
||||
},
|
||||
{
|
||||
title: 'Monergism',
|
||||
author: '',
|
||||
description: 'A comprehensive resource for Reformed theology, including articles, books, and audio resources.',
|
||||
link: 'https://www.monergism.com/',
|
||||
category: 'Websites',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const categories = Array.from(new Set(resources.map((r) => r.category)));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Reformed Resources</h1>
|
||||
|
||||
<p className="mb-10 text-lg text-primary-700">
|
||||
This is a curated collection of resources related to Reformed theology and the doctrines of grace. These
|
||||
books, confessions, and websites have been formative in my own theological journey and are recommended for
|
||||
those seeking to deepen their understanding of Reformed thought.
|
||||
</p>
|
||||
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="mb-12">
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">{category}</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{resources
|
||||
.filter((r) => r.category === category)
|
||||
.map((r) => (
|
||||
<div key={r.title} className="rounded-lg border border-primary-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="mb-2 text-xl font-bold">{r.title}</h3>
|
||||
{r.author && <p className="mb-3 italic text-primary-500">by {r.author}</p>}
|
||||
<p className="mb-4 text-primary-700">{r.description}</p>
|
||||
{r.link && (
|
||||
<a
|
||||
href={r.link}
|
||||
target={r.link.startsWith('http') ? '_blank' : undefined}
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-accent-dark hover:text-accent"
|
||||
>
|
||||
Visit Resource
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="ml-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { getPosts } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get('q') ?? '';
|
||||
const [term, setTerm] = useState(q);
|
||||
const { data: posts, loading } = useAsync(() => getPosts(), []);
|
||||
|
||||
const needle = q.trim().toLowerCase();
|
||||
const results = (posts ?? []).filter((p) =>
|
||||
!needle
|
||||
|| p.title.toLowerCase().includes(needle)
|
||||
|| p.excerpt.toLowerCase().includes(needle)
|
||||
|| p.author.toLowerCase().includes(needle)
|
||||
|| p.tags.some((t) => t.toLowerCase().includes(needle)));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Search</h1>
|
||||
<form
|
||||
className="mb-10 flex gap-3"
|
||||
onSubmit={(e) => { e.preventDefault(); setParams(term.trim() ? { q: term.trim() } : {}); }}
|
||||
>
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Search posts…"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
aria-label="Search posts"
|
||||
/>
|
||||
<button type="submit" className="button">Search</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
{!loading && needle && (
|
||||
<p className="mb-6 text-primary-600">
|
||||
{results.length} {results.length === 1 ? 'result' : 'results'} for “{q}”
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{results.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { getPosts } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
|
||||
export default function TagPage() {
|
||||
const { tag = '' } = useParams();
|
||||
const { data: posts, loading } = useAsync(() => getPosts({ tag }), [tag]);
|
||||
const list = posts ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<h1 className="mb-2 text-3xl font-bold md:text-4xl">
|
||||
Tagged “{tag}”
|
||||
</h1>
|
||||
<p className="mb-10 text-primary-600">
|
||||
{list.length} {list.length === 1 ? 'post' : 'posts'} · <Link to="/tags">all tags</Link>
|
||||
</p>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">Nothing tagged with that yet.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getTags } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
|
||||
export default function TagsPage() {
|
||||
const { data: tags, loading } = useAsync(() => getTags(), []);
|
||||
const list = tags ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h1 className="mb-6 text-3xl font-bold md:text-4xl">Tags</h1>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{list.map((t) => (
|
||||
<Link
|
||||
key={t.tag}
|
||||
to={`/tags/${encodeURIComponent(t.tag)}`}
|
||||
className="rounded-md bg-primary-100 px-3 py-2 text-primary-600 no-underline hover:bg-primary-200"
|
||||
>
|
||||
{t.tag} <span className="text-primary-400">({t.count})</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No tags yet.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useAsync } from '../../lib/useAsync';
|
||||
import {
|
||||
adminDeleteComment, adminDeletePost, adminListComments, adminListPosts, adminListSubscribers,
|
||||
} from '../../api';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type Tab = 'posts' | 'comments' | 'subscribers';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { me, loading } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('posts');
|
||||
const [reload, setReload] = useState(0);
|
||||
const isAdmin = !!me?.admin;
|
||||
|
||||
// Only call the admin API when signed in — otherwise a 401 would bounce us straight to the IdP.
|
||||
const { data: posts } = useAsync(() => (isAdmin ? adminListPosts() : Promise.resolve([])), [isAdmin, reload]);
|
||||
const { data: comments } = useAsync(() => (isAdmin ? adminListComments() : Promise.resolve([])), [isAdmin, reload]);
|
||||
const { data: subscribers } = useAsync(() => (isAdmin ? adminListSubscribers() : Promise.resolve([])), [isAdmin, reload]);
|
||||
|
||||
if (loading) return <p className="text-primary-500">Loading…</p>;
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg text-center">
|
||||
<h1 className="mb-4 text-3xl font-bold">Admin</h1>
|
||||
<p className="mb-8 text-primary-700">Sign in to manage posts, comments and subscribers.</p>
|
||||
<a href="/oauth2/authorization/authentik" className="button">Sign in</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function removePost(slug: string) {
|
||||
if (!confirm(`Delete the post "${slug}"? This cannot be undone.`)) return;
|
||||
await adminDeletePost(slug);
|
||||
setReload((r) => r + 1);
|
||||
}
|
||||
|
||||
async function removeComment(id: number) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
await adminDeleteComment(id);
|
||||
setReload((r) => r + 1);
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string; count: number }[] = [
|
||||
{ id: 'posts', label: 'Posts', count: posts?.length ?? 0 },
|
||||
{ id: 'comments', label: 'Comments', count: comments?.length ?? 0 },
|
||||
{ id: 'subscribers', label: 'Subscribers', count: subscribers?.length ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="mb-0 text-3xl font-bold md:text-4xl">Admin</h1>
|
||||
<Link to="/admin/posts/new" className="button">New post</Link>
|
||||
</div>
|
||||
|
||||
<nav className="mb-8 flex flex-wrap gap-2 border-b border-primary-200 pb-2">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
tab === t.id ? 'bg-accent text-white' : 'text-primary-600 hover:bg-primary-100',
|
||||
)}
|
||||
>
|
||||
{t.label} <span className="opacity-70">({t.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'posts' && (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-primary-200 text-primary-500">
|
||||
<tr>
|
||||
<th className="py-2">Title</th>
|
||||
<th className="py-2">Date</th>
|
||||
<th className="py-2">Status</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(posts ?? []).map((p) => (
|
||||
<tr key={p.slug} className="border-b border-primary-100 last:border-0">
|
||||
<td className="py-3">
|
||||
<div className="font-semibold text-primary-800">{p.title}</div>
|
||||
<div className="text-xs text-primary-500">/{p.slug}</div>
|
||||
</td>
|
||||
<td className="py-3 text-primary-600">{p.date}</td>
|
||||
<td className="py-3">
|
||||
<span className={cn('rounded-md px-2 py-1 text-xs',
|
||||
p.published ? 'bg-accent-light text-accent-dark' : 'bg-primary-100 text-primary-600')}>
|
||||
{p.published ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 text-right whitespace-nowrap">
|
||||
<Link to={`/admin/posts/${encodeURIComponent(p.slug)}/edit`} className="mr-4">Edit</Link>
|
||||
<button onClick={() => removePost(p.slug)} className="text-red-700 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(posts ?? []).length === 0 && (
|
||||
<tr><td colSpan={4} className="py-6 text-primary-500">No posts yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'comments' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{(comments ?? []).map((c) => (
|
||||
<div key={c.id} className="card">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm text-primary-500">
|
||||
<span className="font-semibold text-primary-700">{c.name}</span>
|
||||
<span><{c.email}></span>
|
||||
<span>·</span>
|
||||
<Link to={`/posts/${encodeURIComponent(c.postSlug)}`}>/{c.postSlug}</Link>
|
||||
{c.createdAt && <><span>·</span><span>{format(new Date(c.createdAt), 'MMMM d, yyyy')}</span></>}
|
||||
<button onClick={() => removeComment(c.id)} className="ml-auto text-red-700 hover:text-red-900">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<p className="whitespace-pre-line text-primary-700">{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
{(comments ?? []).length === 0 && <p className="text-primary-500">No comments yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'subscribers' && (
|
||||
<div className="card">
|
||||
<ul className="space-y-1">
|
||||
{(subscribers ?? []).map((email) => (
|
||||
<li key={email} className="text-primary-700">{email}</li>
|
||||
))}
|
||||
</ul>
|
||||
{(subscribers ?? []).length === 0 && <p className="text-primary-500">No subscribers yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChangeEvent, FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { adminCreatePost, adminGetPost, adminUpdatePost, adminUploadImage } from '../../api';
|
||||
import type { PostInput } from '../../api';
|
||||
|
||||
const today = () => new Date().toISOString().split('T')[0];
|
||||
|
||||
const EMPTY: PostInput = {
|
||||
slug: '', title: '', date: today(), excerpt: '', author: '',
|
||||
tags: [], coverImage: null, content: '', published: false,
|
||||
};
|
||||
|
||||
export default function PostEditorPage() {
|
||||
const { slug } = useParams();
|
||||
const editing = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { me, loading: authLoading } = useAuth();
|
||||
const isAdmin = !!me?.admin;
|
||||
|
||||
const [form, setForm] = useState<PostInput>(EMPTY);
|
||||
const [tagsText, setTagsText] = useState('');
|
||||
const [loading, setLoading] = useState(editing);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !isAdmin || !slug) return;
|
||||
adminGetPost(slug)
|
||||
.then((p) => {
|
||||
setForm({
|
||||
slug: p.slug, title: p.title, date: p.date, excerpt: p.excerpt, author: p.author,
|
||||
tags: p.tags, coverImage: p.coverImage, content: p.content, published: p.published,
|
||||
});
|
||||
setTagsText(p.tags.join(', '));
|
||||
})
|
||||
.catch((e) => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [editing, isAdmin, slug]);
|
||||
|
||||
if (authLoading) return <p className="text-primary-500">Loading…</p>;
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg text-center">
|
||||
<h1 className="mb-4 text-3xl font-bold">Admin</h1>
|
||||
<a href="/oauth2/authorization/authentik" className="button">Sign in</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <p className="text-primary-500">Loading post…</p>;
|
||||
|
||||
const set = <K extends keyof PostInput>(key: K, value: PostInput[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
async function onImage(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
set('coverImage', await adminUploadImage(file));
|
||||
} catch (x) {
|
||||
setError(String(x));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError(null);
|
||||
const payload: PostInput = {
|
||||
...form,
|
||||
tags: tagsText.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
};
|
||||
try {
|
||||
if (editing && slug) await adminUpdatePost(slug, payload);
|
||||
else await adminCreatePost(payload);
|
||||
navigate('/admin');
|
||||
} catch (x) {
|
||||
setError(String(x));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="mb-0 text-3xl font-bold">{editing ? 'Edit post' : 'New post'}</h1>
|
||||
<Link to="/admin">Back to admin</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="card space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Title</span>
|
||||
<input className="field" value={form.title} onChange={(e) => set('title', e.target.value)} required />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Slug (URL)</span>
|
||||
<input className="field" value={form.slug} onChange={(e) => set('slug', e.target.value)}
|
||||
disabled={editing} required placeholder="a-certain-sound" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Date</span>
|
||||
<input className="field" type="date" value={form.date} onChange={(e) => set('date', e.target.value)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Author</span>
|
||||
<input className="field" value={form.author} onChange={(e) => set('author', e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Excerpt</span>
|
||||
<textarea className="field min-h-20" value={form.excerpt} onChange={(e) => set('excerpt', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Tags (comma separated)</span>
|
||||
<input className="field" value={tagsText} onChange={(e) => setTagsText(e.target.value)}
|
||||
placeholder="grace, calvinism" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Cover image</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex h-10 cursor-pointer items-center rounded-md border border-primary-300 bg-white px-4 text-sm hover:bg-primary-100">
|
||||
Upload image
|
||||
<input hidden type="file" accept="image/*" onChange={onImage} disabled={busy} />
|
||||
</label>
|
||||
<input className="field flex-1" value={form.coverImage ?? ''}
|
||||
onChange={(e) => set('coverImage', e.target.value || null)}
|
||||
placeholder="/images/example.jpg or an uploaded URL" />
|
||||
</div>
|
||||
{form.coverImage && (
|
||||
<img src={form.coverImage} alt="cover preview" className="mt-3 h-40 rounded-md object-cover" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-semibold text-primary-700">Content (markdown)</span>
|
||||
<textarea className="field min-h-96 font-mono text-sm" value={form.content}
|
||||
onChange={(e) => set('content', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.published} onChange={(e) => set('published', e.target.checked)} />
|
||||
<span className="text-sm font-semibold text-primary-700">Published</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="submit" className="button" disabled={busy}>
|
||||
{busy ? 'Saving…' : editing ? 'Save changes' : 'Create post'}
|
||||
</button>
|
||||
{error && <span className="text-sm text-red-700">{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user