Archived
Add admin UI + MinIO image uploads
- Admin API: list/edit posts (incl. drafts, raw markdown), moderate comments, subscribers, author bio/links, and presigned image upload to MinIO (bucket is public-read for covers). - Admin UI at /admin: post editor with cover-image upload, comment moderation, subscriber list. - Public security switched to authenticated-paths so static assets (/images, /data) stay public.
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user