Archived
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
@@ -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; }
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
Reference in New Issue
Block a user