Archived
Restore the app files (prior commit mis-scoped its tar)
build-and-publish / build (push) Failing after 8s
build-and-publish / build (push) Failing after 8s
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -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,48 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { logout } from '../api';
|
||||
|
||||
export default function Header() {
|
||||
const { me } = useAuth();
|
||||
|
||||
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 items-center 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>
|
||||
{me?.admin && (
|
||||
<>
|
||||
<Link to="/admin" className="font-semibold text-accent-dark hover:text-accent">Admin</Link>
|
||||
<button onClick={() => logout()} className="text-primary-500 hover:text-accent-dark">
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user