Rebuild on the Bennett platform: Spring Boot + Vite/React

Same site — glass header, bento grid, hero drift, dark mode — with three things fixed
on the way:

- Tailwind and lucide came from CDNs on every page load, and the fonts from Google. All
  are now built in or self-hosted, so the site owes nothing to third parties at runtime.
- The hero and bento photographs were hot-linked from Unsplash. They are re-encoded to
  webp and served from the MinIO bucket with a year-long cache.
- The ministries and labs were hard-coded in the markup, so launching a ministry meant
  editing HTML. They now come from /api/network.

The mobile menu button also opens something now; it did nothing before.
This commit is contained in:
2026-07-23 07:30:01 -05:00
parent fe71a1864c
commit 37a387e7b1
33 changed files with 2551 additions and 323 deletions
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📖</text></svg>">
<link rel="preconnect" href="https://s3.thebennett.net" crossorigin>
<title>RWN | Timeless Truth</title>
<meta name="description" content="Reformed Witness Network — upholding the historic Reformed tradition through accessible media, scholarship, and community.">
<meta property="og:title" content="RWN | Timeless Truth">
<meta property="og:description" content="Upholding the historic Reformed tradition through accessible media, scholarship, and community.">
<meta property="og:type" content="website">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1388
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "rwn-website-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"lucide-react": "1.25.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
"@types/node": "^26.1.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"tailwindcss": "4.3.3",
"typescript": "^5.9.0",
"vite": "^8.1.3"
}
}
+350
View File
@@ -0,0 +1,350 @@
import { useEffect, useRef, useState } from 'react';
import {
ArrowRight, BookOpen, ChevronDown, ExternalLink, Menu, Moon, Share2, Sun, Users,
} from 'lucide-react';
import GithubMark from './components/GithubMark';
interface MinistryView {
name: string;
blurb: string;
linkUrl: string | null;
linkLabel: string | null;
badge: string | null;
style: 'FEATURE' | 'LIGHT' | 'DARK' | 'OUTLINE';
imageUrl: string | null;
statusNote: string | null;
}
interface LabView { name: string; repo: string; linkUrl: string }
interface Network { ministries: MinistryView[]; labs: LabView[] }
/** Fades a section up the first time it scrolls into view. */
function useReveal<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('reveal-active');
observer.unobserve(e.target);
}
}),
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return ref;
}
function ScrollProgress() {
const [pct, setPct] = useState(0);
useEffect(() => {
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
setPct(max > 0 ? window.scrollY / max : 0);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<div
className="fixed top-0 left-0 h-1 bg-brand-500 w-full z-[100] origin-left"
style={{ transform: `scaleX(${pct})` }}
aria-hidden="true"
/>
);
}
function ThemeToggle() {
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'));
const toggle = () => {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle('dark', next);
localStorage.setItem('rwn-theme', next ? 'dark' : 'light');
};
return (
<button
onClick={toggle}
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="p-2 rounded-full hover:bg-brand-100 dark:hover:bg-brand-900/30 transition-colors ml-4"
>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
);
}
/** The four bento treatments. Which one a ministry gets is data; how it looks is here. */
function MinistryCard({ m }: { m: MinistryView }) {
if (m.style === 'FEATURE') {
return (
<div className="md:col-span-8 md:row-span-2 relative group overflow-hidden rounded-[2.5rem] bento-card shadow-lg">
{m.imageUrl && (
<img
src={m.imageUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-80 transition-transform duration-700 group-hover:scale-105"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
{m.badge && (
<div className="absolute top-8 right-8 flex gap-2">
<span className="bg-brand-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-1.5 rounded-full">
{m.badge}
</span>
</div>
)}
<div className="absolute bottom-0 left-0 p-10">
<h3 className="text-4xl font-serif text-white mb-4">{m.name}</h3>
<p className="text-stone-300 mb-6 max-w-md">{m.blurb}</p>
{m.linkUrl && (
<a
href={m.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-brand-500 font-bold hover:text-white transition-colors"
>
{m.linkLabel} <ExternalLink className="w-4 h-4" />
</a>
)}
</div>
</div>
);
}
if (m.style === 'LIGHT') {
return (
<div className="md:col-span-4 md:row-span-1 bg-brand-100 dark:bg-stone-800 rounded-[2.5rem] p-10 flex flex-col justify-between bento-card">
<div>
<h3 className="text-2xl font-serif mb-2">{m.name}</h3>
<p className="text-stone-600 dark:text-stone-400 text-sm">{m.blurb}</p>
</div>
{m.linkUrl && (
<a
href={m.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-600 dark:text-brand-500 font-bold flex items-center gap-2"
>
{m.linkLabel} <ArrowRight className="w-4 h-4" />
</a>
)}
</div>
);
}
if (m.style === 'DARK') {
return (
<div className="md:col-span-4 md:row-span-1 bg-stone-900 text-white rounded-[2.5rem] p-10 flex flex-col justify-between bento-card overflow-hidden relative">
<Share2 className="absolute -right-4 -top-4 w-24 h-24 opacity-5" aria-hidden="true" />
<div className="relative z-10">
<h3 className="text-2xl font-serif mb-2">{m.name}</h3>
<p className="text-stone-400 text-sm">{m.blurb}</p>
</div>
{m.linkUrl && (
<a
href={m.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 font-bold flex items-center gap-2 relative z-10"
>
{m.linkLabel} <Users className="w-4 h-4" />
</a>
)}
</div>
);
}
return (
<div className="md:col-span-12 md:row-span-1 border-2 border-dashed border-stone-200 dark:border-stone-800 rounded-[2.5rem] p-10 flex flex-col md:flex-row items-center justify-between bento-card group">
<div className="max-w-xl text-center md:text-left">
<h3 className="text-2xl font-serif mb-2">{m.name}</h3>
<p className="text-stone-500 text-sm leading-relaxed">{m.blurb}</p>
</div>
<div className="mt-6 md:mt-0 flex flex-col items-center md:items-end">
<span className="text-[10px] font-bold text-brand-500 uppercase tracking-widest mb-2">
Archive Research
</span>
{m.statusNote && (
<div className="px-6 py-2 bg-stone-100 dark:bg-stone-800 rounded-full text-stone-400 text-xs font-mono">
{m.statusNote}
</div>
)}
</div>
</div>
);
}
export default function App() {
const [network, setNetwork] = useState<Network | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const ministriesRef = useReveal<HTMLDivElement>();
const labsRef = useReveal<HTMLDivElement>();
useEffect(() => {
fetch('/api/network', { headers: { Accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then(setNetwork)
.catch(() => setNetwork(null));
}, []);
const nav = [
['#about', 'Vision'],
['#ministries', 'Ministries'],
['#labs', 'Labs'],
];
return (
<div className="bg-brand-50 dark:bg-obsidian text-stone-900 dark:text-stone-100 transition-colors duration-500">
<ScrollProgress />
<header className="fixed w-full top-0 z-50 glass border-b border-stone-200/50 dark:border-stone-800/50">
<div className="container mx-auto px-6 h-20 flex items-center justify-between">
<a href="#" className="flex items-center gap-2 group">
<div className="p-2 bg-brand-500 rounded-lg text-white group-hover:rotate-12 transition-transform duration-300">
<BookOpen className="w-5 h-5" />
</div>
<span className="text-xl font-bold font-serif tracking-tight">
RWN<span className="text-brand-500">.</span>
</span>
</a>
<nav className="hidden lg:flex items-center gap-8 font-medium text-xs uppercase tracking-[0.2em]">
{nav.map(([href, label]) => (
<a key={href} href={href} className="hover:text-brand-500 transition-colors">{label}</a>
))}
<ThemeToggle />
<a
href="https://subsplash.com/reformedwitness/give"
target="_blank"
rel="noopener noreferrer"
className="bg-brand-900 dark:bg-brand-500 text-white px-6 py-2.5 rounded-full transition-all"
>
Support
</a>
</nav>
<button
className="lg:hidden p-2"
onClick={() => setMenuOpen((o) => !o)}
aria-label="Toggle menu"
aria-expanded={menuOpen}
>
<Menu />
</button>
</div>
{/* The old site had a menu button that opened nothing on mobile. */}
{menuOpen && (
<nav className="lg:hidden border-t border-stone-200/50 dark:border-stone-800/50 px-6 py-4 flex flex-col gap-4 text-xs uppercase tracking-[0.2em]">
{nav.map(([href, label]) => (
<a
key={href}
href={href}
onClick={() => setMenuOpen(false)}
className="hover:text-brand-500 transition-colors"
>
{label}
</a>
))}
<div className="flex items-center justify-between">
<a
href="https://subsplash.com/reformedwitness/give"
target="_blank"
rel="noopener noreferrer"
className="bg-brand-900 dark:bg-brand-500 text-white px-6 py-2.5 rounded-full"
>
Support
</a>
<ThemeToggle />
</div>
</nav>
)}
</header>
<main>
<section id="about" className="relative min-h-[85vh] flex items-center pt-20 overflow-hidden">
<div className="absolute inset-0 z-0">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-brand-50 dark:to-obsidian z-10" />
<img
src="https://s3.thebennett.net/rwn/images/hero-library.webp"
className="w-full h-full object-cover opacity-20 dark:opacity-10 scale-105 hero-zoom"
alt=""
fetchPriority="high"
/>
</div>
<div className="container mx-auto px-6 relative z-20">
<div className="max-w-4xl">
<h1 className="text-6xl md:text-8xl font-serif mb-8 leading-[1.1]">
Timeless Truth. <br />
<span className="italic text-brand-500">Modern Witness.</span>
</h1>
<p className="text-xl md:text-2xl text-stone-600 dark:text-stone-400 mb-10 max-w-2xl leading-relaxed">
Upholding the historic Reformed tradition through accessible media, scholarship, and
community.
</p>
<a
href="#ministries"
className="px-8 py-4 bg-brand-500 text-white rounded-xl font-semibold hover:bg-brand-600 transition-all inline-flex items-center gap-2"
>
Our Network <ChevronDown className="w-4 h-4" />
</a>
</div>
</div>
</section>
<section id="ministries" className="py-24 bg-white dark:bg-neutral-900 transition-colors">
<div ref={ministriesRef} className="container mx-auto px-6 reveal-init">
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 auto-rows-[300px]">
{(network?.ministries ?? []).map((m) => <MinistryCard key={m.name} m={m} />)}
</div>
</div>
</section>
<section
id="labs"
className="py-24 bg-brand-50 dark:bg-obsidian border-y border-stone-200 dark:border-stone-800"
>
<div ref={labsRef} className="container mx-auto px-6 reveal-init">
<div className="flex items-center gap-4 mb-12">
<h2 className="text-3xl font-serif">RWN Labs</h2>
<div className="h-px flex-grow bg-brand-500/20" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{(network?.labs ?? []).map((l) => (
<div
key={l.repo}
className="bg-white dark:bg-neutral-900 p-8 rounded-3xl border border-stone-200 dark:border-stone-800 hover:border-brand-500 transition-all"
>
<h3 className="text-xl font-serif mb-2">{l.name}</h3>
<p className="text-stone-500 text-xs font-mono mb-4">repo: {l.repo}</p>
<a
href={l.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 font-bold text-xs flex items-center gap-2"
>
VIEW SOURCE <GithubMark className="w-4 h-4" />
</a>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="bg-brand-50 dark:bg-obsidian py-16 text-center border-t border-stone-200 dark:border-stone-800">
<div className="font-serif text-2xl mb-8">
RWN<span className="text-brand-500">.</span>
</div>
<p className="text-[10px] uppercase tracking-[0.4em] text-stone-400">
© {new Date().getFullYear()} Reformed Witness Network
</p>
</footer>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* The GitHub mark, inline.
*
* lucide-react dropped brand icons in v1, and the old site rendered `data-lucide="github"` from the
* unpkg build. Substituting a generic code or branch icon would change what the button says, so the
* mark is drawn here instead.
*/
export default function GithubMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
focusable="false"
className={className}
>
<path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.79 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.12 3.05.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14 0 1.55-.01 2.79-.01 3.17 0 .31.2.68.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
</svg>
);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
@import "tailwindcss";
/* Dark mode is a class on <html>, toggled in the header — same as the old site. In Tailwind v4 the
`dark:` variant has to be told that. */
@custom-variant dark (&:where(.dark, .dark *));
/* Self-hosted, latin subset. The old site pulled these from Google on every page load. */
@font-face {
font-family: 'Outfit';
src: url('./fonts/outfit-latin.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: 'Playfair Display';
src: url('./fonts/playfair-latin.woff2') format('woff2');
font-weight: 400 900;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('./fonts/firacode-latin.woff2') format('woff2');
font-weight: 300 700;
font-display: swap;
}
@theme {
--color-brand-50: #fbf8f4;
--color-brand-100: #f4ece1;
--color-brand-500: #b88b5a;
--color-brand-600: #9e754a;
--color-brand-900: #433424;
--color-obsidian: #121212;
--font-sans: 'Outfit', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Playfair Display', ui-serif, Georgia, serif;
--font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, monospace;
}
body {
font-family: var(--font-sans);
}
/* Frosted header and cards. */
.glass {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.dark .glass {
background: rgba(18, 18, 18, 0.75);
}
/* Bento card physics. */
.bento-card {
transition: all 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bento-card:hover {
transform: translateY(-10px) scale(1.01);
}
/* Slow drift on the hero photograph. */
@keyframes heroZoom {
0% { transform: scale(1.05); }
100% { transform: scale(1.15); }
}
.hero-zoom {
animation: heroZoom 20s infinite alternate ease-in-out;
}
/* Sections fade up as they come into view; see useReveal. */
.reveal-init {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
}
.reveal-active {
opacity: 1 !important;
transform: translateY(0) !important;
}
@media (prefers-reduced-motion: reduce) {
.hero-zoom { animation: none; }
.bento-card { transition: none; }
.reveal-init { opacity: 1; transform: none; transition: none; }
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
// Respect the reader's saved choice, then the system, before first paint of the app.
const saved = localStorage.getItem('rwn-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client", "node"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src", "vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
server: { port: 5173, proxy: { '/api': 'http://localhost:8080' } },
build: { outDir: 'dist' },
});