Archived
Rewrite as a Spring Boot app on the Bennett platform
build-and-publish / build (push) Successful in 1m38s
build-and-publish / build (push) Successful in 1m38s
Restores the real backend lost with Supabase (posts, authors, comments, subscriptions, admin) in Postgres, seeds the existing markdown posts, and rebuilds the site as a Vite/React SPA served by Spring — keeping the original styling (serif + tan accent) and content. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import HomePage from './pages/HomePage';
|
||||
import PostsPage from './pages/PostsPage';
|
||||
import PostPage from './pages/PostPage';
|
||||
import AuthorsPage from './pages/AuthorsPage';
|
||||
import AuthorPage from './pages/AuthorPage';
|
||||
import TagsPage from './pages/TagsPage';
|
||||
import TagPage from './pages/TagPage';
|
||||
import ConfessionPage from './pages/ConfessionPage';
|
||||
import AboutPage from './pages/AboutPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="container mx-auto grow px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/posts" element={<PostsPage />} />
|
||||
<Route path="/posts/:slug" element={<PostPage />} />
|
||||
<Route path="/authors" element={<AuthorsPage />} />
|
||||
<Route path="/authors/:name" element={<AuthorPage />} />
|
||||
<Route path="/tags" element={<TagsPage />} />
|
||||
<Route path="/tags/:tag" element={<TagPage />} />
|
||||
<Route path="/confession" element={<ConfessionPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createApi } from './lib/http';
|
||||
import type {
|
||||
AuthorPage, AuthorSummary, CommentView, MeInfo, PostDetail, PostSummary, TagCount,
|
||||
} from './types';
|
||||
|
||||
const api = createApi();
|
||||
|
||||
// ---- auth (public site: only /api/admin/** requires a login) ----
|
||||
export const getMe = () => api.get<MeInfo>('/me');
|
||||
export const login = () => api.login();
|
||||
export const logout = () => api.logout();
|
||||
|
||||
// ---- posts / tags / authors ----
|
||||
export function getPosts(params: { tag?: string; author?: string } = {}): Promise<PostSummary[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.tag) qs.set('tag', params.tag);
|
||||
if (params.author) qs.set('author', params.author);
|
||||
const q = qs.toString();
|
||||
return api.get<PostSummary[]>(`/posts${q ? `?${q}` : ''}`);
|
||||
}
|
||||
export const getPost = (slug: string) => api.get<PostDetail>(`/posts/${encodeURIComponent(slug)}`);
|
||||
export const getTags = () => api.get<TagCount[]>('/tags');
|
||||
export const getAuthors = () => api.get<AuthorSummary[]>('/authors');
|
||||
export const getAuthor = (name: string) => api.get<AuthorPage>(`/authors/${encodeURIComponent(name)}`);
|
||||
|
||||
// ---- comments / subscriptions ----
|
||||
export const getComments = (postId: string) =>
|
||||
api.get<CommentView[]>(`/comments?postId=${encodeURIComponent(postId)}`);
|
||||
|
||||
export const postComment = (input: { postId: string; name: string; email: string; comment: string }) =>
|
||||
api.post<CommentView>('/comments', input);
|
||||
|
||||
export const subscribe = (email: string) => api.post<void>('/subscriptions', { email });
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { getComments, postComment } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
|
||||
export default function CommentSection({ postId }: { postId: string }) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const { data: comments, loading } = useAsync(() => getComments(postId), [postId, reloadKey]);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'busy' | 'error'>('idle');
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !email.trim() || !comment.trim()) return;
|
||||
setState('busy');
|
||||
try {
|
||||
await postComment({ postId, name: name.trim(), email: email.trim(), comment: comment.trim() });
|
||||
setName(''); setEmail(''); setComment('');
|
||||
setState('idle');
|
||||
setReloadKey((k) => k + 1);
|
||||
} catch {
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
const list = comments ?? [];
|
||||
|
||||
return (
|
||||
<section className="mt-12">
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">
|
||||
{list.length === 1 ? '1 Comment' : `${list.length} Comments`}
|
||||
</h2>
|
||||
|
||||
{loading && <p className="text-primary-500">Loading comments…</p>}
|
||||
|
||||
<ul className="mb-10 space-y-4">
|
||||
{list.map((c) => (
|
||||
<li key={c.id} className="card">
|
||||
<div className="mb-1 flex items-center text-sm text-primary-500">
|
||||
<span className="font-semibold text-primary-700">{c.name}</span>
|
||||
{c.createdAt && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<time dateTime={c.createdAt}>{format(new Date(c.createdAt), 'MMMM d, yyyy')}</time>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-line text-primary-700">{c.body}</p>
|
||||
</li>
|
||||
))}
|
||||
{!loading && list.length === 0 && (
|
||||
<li className="text-primary-500">No comments yet — be the first to share a thought.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-xl font-bold">Leave a comment</h3>
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<input className="field" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<input className="field" type="email" placeholder="Email (not published)" value={email}
|
||||
onChange={(e) => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<textarea className="field min-h-32" placeholder="Your comment" value={comment}
|
||||
onChange={(e) => setComment(e.target.value)} required />
|
||||
<button type="submit" className="button" disabled={state === 'busy'}>
|
||||
{state === 'busy' ? 'Posting…' : 'Post Comment'}
|
||||
</button>
|
||||
{state === 'error' && <p className="text-sm text-red-700">Something went wrong. Please try again.</p>}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import SubscribeForm from './SubscribeForm';
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="mt-16 bg-primary-800 text-white">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div>
|
||||
<h3 className="mb-4 text-xl font-bold text-white">Confessions of Grace</h3>
|
||||
<p className="text-primary-300">
|
||||
A blog dedicated to exploring the doctrines of grace and Reformed theology.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-xl font-bold text-white">Navigation</h3>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/" className="text-primary-300 hover:text-white">Home</Link></li>
|
||||
<li><Link to="/about" className="text-primary-300 hover:text-white">About</Link></li>
|
||||
<li><Link to="/posts" className="text-primary-300 hover:text-white">Archive</Link></li>
|
||||
<li><Link to="/confession" className="text-primary-300 hover:text-white">1689 Confession</Link></li>
|
||||
<li><Link to="/resources" className="text-primary-300 hover:text-white">Resources</Link></li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.etsy.com/shop/ConfessionsOfGrace"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary-300 hover:text-white"
|
||||
>
|
||||
Shop
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-xl font-bold text-white">Subscribe</h3>
|
||||
<p className="mb-4 text-primary-300">Stay updated with the latest posts.</p>
|
||||
<SubscribeForm placeholder="Your email" buttonLabel="Subscribe" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-primary-700 pt-8 text-center text-primary-400">
|
||||
<p>© {currentYear} Confessions of Grace. All rights reserved.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
“For by grace you have been saved through faith. And this is not your own doing; it is the
|
||||
gift of God.” — Ephesians 2:8
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="border-b border-primary-200 bg-white shadow-sm">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<Link to="/" className="no-underline">
|
||||
<div className="mb-4 flex items-center space-x-4 md:mb-0">
|
||||
<img src="/assets/logo.svg" alt="Confessions of Grace" className="h-auto max-h-12 w-auto" />
|
||||
<div>
|
||||
<h1 className="mb-0 text-3xl font-bold text-primary-900">Confessions of Grace</h1>
|
||||
<p className="text-sm italic text-primary-500">Confessing Christ. Rejoicing in Grace.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="flex flex-wrap justify-center gap-x-6 gap-y-2">
|
||||
<Link to="/" className="text-primary-700 hover:text-accent-dark">Home</Link>
|
||||
<Link to="/about" className="text-primary-700 hover:text-accent-dark">About</Link>
|
||||
<Link to="/posts" className="text-primary-700 hover:text-accent-dark">Archive</Link>
|
||||
<Link to="/confession" className="text-primary-700 hover:text-accent-dark">1689 Confession</Link>
|
||||
<Link to="/resources" className="text-primary-700 hover:text-accent-dark">Resources</Link>
|
||||
<a
|
||||
href="https://www.etsy.com/shop/ConfessionsOfGrace"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary-700 hover:text-accent-dark"
|
||||
>
|
||||
Shop
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import type { PostSummary } from '../types';
|
||||
|
||||
export default function PostCard({ post }: { post: PostSummary }) {
|
||||
return (
|
||||
<article className="card transition-shadow duration-200 hover:shadow-md">
|
||||
{post.coverImage && (
|
||||
<div className="relative mb-4 h-48 w-full overflow-hidden rounded-md">
|
||||
<img src={post.coverImage} alt={post.title} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="mb-2 text-xl font-bold">
|
||||
<Link to={`/posts/${post.slug}`} className="hover:text-accent-dark">{post.title}</Link>
|
||||
</h2>
|
||||
<div className="mb-2 flex items-center text-sm text-primary-500">
|
||||
<span>{post.author}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<time dateTime={post.date}>{format(new Date(post.date), 'MMMM d, yyyy')}</time>
|
||||
</div>
|
||||
<p className="mb-4 text-primary-600">{post.excerpt}</p>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/tags/${encodeURIComponent(tag)}`}
|
||||
className="rounded-md bg-primary-100 px-2 py-1 text-xs text-primary-600 no-underline hover:bg-primary-200"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link to={`/posts/${post.slug}`} className="inline-flex items-center text-accent-dark hover:text-accent">
|
||||
Read more
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import type { PostSummary, TagCount } from '../types';
|
||||
import SubscribeForm from './SubscribeForm';
|
||||
|
||||
interface Props {
|
||||
recentPosts: PostSummary[];
|
||||
tags: TagCount[];
|
||||
}
|
||||
|
||||
export default function Sidebar({ recentPosts, tags }: Props) {
|
||||
return (
|
||||
<aside className="flex flex-col gap-8">
|
||||
<div className="card">
|
||||
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Recent Posts</h3>
|
||||
<ul className="space-y-3">
|
||||
{recentPosts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link to={`/posts/${post.slug}`} className="hover:text-accent">{post.title}</Link>
|
||||
<div className="text-sm text-primary-500">
|
||||
{format(new Date(post.date), 'MMMM d, yyyy')}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{recentPosts.length === 0 && <li className="text-primary-500">No posts yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.tag}
|
||||
to={`/tags/${encodeURIComponent(t.tag)}`}
|
||||
className="rounded-md bg-primary-100 px-2 py-1 text-sm text-primary-600 no-underline hover:bg-primary-200"
|
||||
>
|
||||
{t.tag} <span className="text-primary-400">({t.count})</span>
|
||||
</Link>
|
||||
))}
|
||||
{tags.length === 0 && <span className="text-primary-500">No tags yet.</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="mb-4 border-b border-primary-200 pb-2 text-xl font-bold">Subscribe</h3>
|
||||
<p className="mb-4 text-primary-600">Stay updated with the latest posts.</p>
|
||||
<SubscribeForm />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { subscribe } from '../api';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
buttonLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubscribeForm({
|
||||
placeholder = 'Your email',
|
||||
buttonLabel = 'Subscribe',
|
||||
className,
|
||||
}: Props) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle');
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
setState('busy');
|
||||
try {
|
||||
await subscribe(email.trim());
|
||||
setState('done');
|
||||
setEmail('');
|
||||
} catch {
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'done') {
|
||||
return <p className="text-sm text-accent-light">Thank you — you're subscribed.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className={cn('flex flex-col space-y-2', className)}>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Email address"
|
||||
className="field"
|
||||
/>
|
||||
<button type="submit" className="button" disabled={state === 'busy'}>
|
||||
{state === 'busy' ? 'Subscribing…' : buttonLabel}
|
||||
</button>
|
||||
{state === 'error' && <p className="text-sm text-red-300">Something went wrong. Please try again.</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Confessions of Grace brand — carried over verbatim from the original site so
|
||||
the rebuild looks identical: serif type, warm tan accent, soft grey scale.
|
||||
This file is the ONLY place the app's look is defined.
|
||||
--------------------------------------------------------------------------- */
|
||||
@theme {
|
||||
--color-primary-50: #f8f9fa;
|
||||
--color-primary-100: #e9ecef;
|
||||
--color-primary-200: #dee2e6;
|
||||
--color-primary-300: #ced4da;
|
||||
--color-primary-400: #adb5bd;
|
||||
--color-primary-500: #6c757d;
|
||||
--color-primary-600: #495057;
|
||||
--color-primary-700: #343a40;
|
||||
--color-primary-800: #212529;
|
||||
--color-primary-900: #121212;
|
||||
|
||||
--color-accent-light: #e2d8c6;
|
||||
--color-accent: #9d8c70;
|
||||
--color-accent-dark: #695c4a;
|
||||
|
||||
--font-serif: Baskerville, Georgia, "Times New Roman", serif;
|
||||
--font-sans: "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-primary-50 text-primary-800 font-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-serif text-primary-900 mb-4 font-bold;
|
||||
}
|
||||
|
||||
h1 { @apply text-3xl md:text-4xl; }
|
||||
h2 { @apply text-2xl md:text-3xl; }
|
||||
h3 { @apply text-xl md:text-2xl; }
|
||||
|
||||
a {
|
||||
@apply text-accent-dark hover:text-accent transition-colors duration-200;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply border-l-4 border-accent pl-4 italic my-4;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.blog-post {
|
||||
@apply prose prose-lg max-w-none;
|
||||
}
|
||||
|
||||
.blog-post h1, .blog-post h2, .blog-post h3 {
|
||||
@apply border-b border-primary-200 pb-2;
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply inline-block px-4 py-2 rounded-md bg-accent text-white no-underline shadow-sm
|
||||
hover:bg-accent-dark hover:text-white transition-colors duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-md shadow-sm p-6 border border-primary-200
|
||||
hover:shadow-md transition-shadow duration-200;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full rounded-md border border-primary-300 bg-white px-3 py-2 text-primary-800
|
||||
focus:border-accent focus:outline-none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Shared HTTP core (canonical copy: platform/frontend-template/src/lib/http.ts). Same-origin fetch to
|
||||
// /api, CSRF from the XSRF-TOKEN cookie, and 401 -> Authentik login redirect (only admin routes are gated).
|
||||
|
||||
export interface HttpOptions {
|
||||
loginUrl?: string;
|
||||
base?: string;
|
||||
}
|
||||
|
||||
function csrfHeader(): Record<string, string> {
|
||||
const m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
||||
return m ? { 'X-XSRF-TOKEN': decodeURIComponent(m[1]) } : {};
|
||||
}
|
||||
|
||||
export function createApi(opts: HttpOptions = {}) {
|
||||
const loginUrl = opts.loginUrl ?? '/oauth2/authorization/authentik';
|
||||
const base = opts.base ?? '/api';
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(base + path, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...csrfHeader(), ...(init?.headers ?? {}) },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
window.location.href = loginUrl;
|
||||
throw new Error('unauthenticated');
|
||||
}
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T>(path: string) => req<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
req<T>(path, { method: 'POST', body: body != null ? JSON.stringify(body) : undefined }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
req<T>(path, { method: 'PUT', body: body != null ? JSON.stringify(body) : undefined }),
|
||||
del: <T>(path: string) => req<T>(path, { method: 'DELETE' }),
|
||||
login(): void {
|
||||
window.location.href = loginUrl;
|
||||
},
|
||||
logout(): void {
|
||||
// Full-page form POST so the browser follows the RP-initiated logout redirect chain.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Api = ReturnType<typeof createApi>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Minimal data-loading helper: runs `fn` on mount / when `deps` change. */
|
||||
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fn()
|
||||
.then((d) => { if (alive) { setData(d); setLoading(false); } })
|
||||
.catch((e) => { if (alive) { setError(String(e)); setLoading(false); } });
|
||||
return () => { alive = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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,49 @@
|
||||
export interface PostSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
date: string;
|
||||
excerpt: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
coverImage: string | null;
|
||||
}
|
||||
|
||||
export interface PostDetail extends PostSummary {
|
||||
contentHtml: string;
|
||||
}
|
||||
|
||||
export interface AuthorSummary {
|
||||
name: string;
|
||||
bio: string;
|
||||
pfpLink: string | null;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
export interface AuthorPage {
|
||||
name: string;
|
||||
bio: string;
|
||||
xLink: string | null;
|
||||
fbLink: string | null;
|
||||
instaLink: string | null;
|
||||
pfpLink: string | null;
|
||||
postCount: number;
|
||||
posts: PostSummary[];
|
||||
}
|
||||
|
||||
export interface TagCount {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface CommentView {
|
||||
id: number;
|
||||
name: string;
|
||||
body: string;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface MeInfo {
|
||||
authenticated: boolean;
|
||||
admin: boolean;
|
||||
owner: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user