Rebuild on the Bennett platform: Spring Boot + Vite/React
build-and-publish / build (push) Successful in 1m18s

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.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
2026-07-23 07:30:01 -05:00
co-authored by Claude Opus 4.8
parent f2be8ce247
commit 8abca06a86
33 changed files with 2551 additions and 323 deletions
+7 -4
View File
@@ -1,4 +1,7 @@
.git
Dockerfile
.dockerignore
README.md
target/
frontend/node_modules/
frontend/dist/
.git/
.idea/
*.iml
.vscode/
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>gitea</id>
<username>${env.MAVEN_USER}</username>
<password>${env.MAVEN_TOKEN}</password>
</server>
</servers>
<profiles>
<profile>
<id>gitea</id>
<repositories>
<repository>
<id>gitea</id>
<url>https://git.thebennett.net/api/packages/austin/maven</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles><activeProfile>gitea</activeProfile></activeProfiles>
</settings>
+49
View File
@@ -0,0 +1,49 @@
name: build-and-publish
on:
push:
branches: [main]
# Files that can't change the image. Skipping them avoids a pointless rebuild that Watchtower
# would then redeploy — a few seconds of downtime on a live site for a docs-only commit.
paths-ignore: ["renovate.json", "**.md"]
# Lets `rebuild-all-apps.sh` force a rebuild (e.g. to roll out an urgent platform fix immediately
# instead of waiting for a Renovate bump PR).
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
# The platform is now referenced by an immutable RELEASE version, so a cached maven layer can't
# silently hold an old build — layer caching is safe again (and much faster).
- name: Build image
env:
DOCKER_BUILDKIT: "1"
MAVEN_USER: ${{ secrets.REGISTRY_USER }}
MAVEN_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
docker build \
--secret id=maven_user,env=MAVEN_USER \
--secret id=maven_token,env=MAVEN_TOKEN \
--build-arg GIT_SHA=${{ github.sha }} \
-t git.thebennett.net/reformedwitness/rwn-website:latest \
-t git.thebennett.net/reformedwitness/rwn-website:${{ github.sha }} .
- name: Scan image (Trivy)
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
git.thebennett.net/reformedwitness/rwn-website:latest || true
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
--pkg-types library --exit-code 1 --no-progress \
git.thebennett.net/reformedwitness/rwn-website:latest
- name: Push image
run: |
docker push git.thebennett.net/reformedwitness/rwn-website:latest
docker push git.thebennett.net/reformedwitness/rwn-website:${{ github.sha }}
-16
View File
@@ -1,16 +0,0 @@
name: build-and-publish
on:
push:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to the Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build + push
run: |
docker build -t git.thebennett.net/reformedwitness/rwn-website:latest -t git.thebennett.net/reformedwitness/rwn-website:${{ github.sha }} .
docker push git.thebennett.net/reformedwitness/rwn-website:latest
docker push git.thebennett.net/reformedwitness/rwn-website:${{ github.sha }}
+7
View File
@@ -0,0 +1,7 @@
target/
frontend/node_modules/
frontend/dist/
.idea/
*.iml
.vscode/
.DS_Store
+36 -4
View File
@@ -1,5 +1,37 @@
# syntax=docker/dockerfile:1
FROM nginx:1.27-alpine
COPY . /usr/share/nginx/html/
RUN rm -f /usr/share/nginx/html/README.md /usr/share/nginx/html/Dockerfile /usr/share/nginx/html/.dockerignore
EXPOSE 80
# ---------- build ----------
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /src
COPY . .
# Resolve the platform from the Gitea Maven registry (creds via BuildKit secrets); the Maven build also
# runs the Vite/React SPA build (frontend-maven-plugin) and folds it into the jar. Tests are skipped here
# because Testcontainers needs a Docker daemon — they run via `mvn verify`, not in the image build.
RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
MAVEN_USER="$(cat /run/secrets/maven_user)" MAVEN_TOKEN="$(cat /run/secrets/maven_token)" \
mvn -B -ntp -s .gitea/ci-settings.xml -DskipTests package \
&& cp "$(ls target/rwn-website-*.jar | grep -v original | head -1)" app.jar \
&& java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---------- runtime ----------
FROM eclipse-temurin:25-jre-alpine AS runtime
RUN apk -U upgrade --no-cache && apk add --no-cache curl
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
WORKDIR /app
COPY --from=build --chown=spring:spring /src/extracted/dependencies/ ./
COPY --from=build --chown=spring:spring /src/extracted/snapshot-dependencies/ ./
COPY --from=build --chown=spring:spring /src/extracted/application/ ./
USER spring
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.title="rwn-website" \
org.opencontainers.image.source="https://git.thebennett.net/reformedwitness/rwn-website" \
org.opencontainers.image.revision="${GIT_SHA}"
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
-1
View File
@@ -1 +0,0 @@
# rwn-website
+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' },
});
-179
View File
@@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RWN | Timeless Truth</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Outfit:wght@300;400;600&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
<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>">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="style.css">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#fbf8f4',
100: '#f4ece1',
500: '#B88B5A',
600: '#9E754A',
900: '#433424',
},
obsidian: '#121212'
},
fontFamily: {
sans: ['Outfit', 'sans-serif'],
serif: ['Playfair Display', 'serif'],
mono: ['Fira Code', 'monospace'],
}
}
}
}
</script>
</head>
<body class="bg-brand-50 dark:bg-obsidian text-stone-900 dark:text-stone-100 transition-colors duration-500">
<div id="scroll-progress" class="fixed top-0 left-0 h-1 bg-brand-500 w-full z-[100] scale-x-0"></div>
<header class="fixed w-full top-0 z-50 glass border-b border-stone-200/50 dark:border-stone-800/50">
<div class="container mx-auto px-6 h-20 flex items-center justify-between">
<a href="#" class="flex items-center gap-2 group">
<div class="p-2 bg-brand-500 rounded-lg text-white group-hover:rotate-12 transition-transform duration-300">
<i data-lucide="book-open"></i>
</div>
<span class="text-xl font-bold font-serif tracking-tight">RWN<span class="text-brand-500">.</span></span>
</a>
<nav class="hidden lg:flex items-center gap-8 font-medium text-xs uppercase tracking-[0.2em]">
<a href="#about" class="hover:text-brand-500 transition-colors">Vision</a>
<a href="#ministries" class="hover:text-brand-500 transition-colors">Ministries</a>
<a href="#labs" class="hover:text-brand-500 transition-colors">Labs</a>
<button id="theme-toggle" class="p-2 rounded-full hover:bg-brand-100 dark:hover:bg-brand-900/30 transition-colors ml-4">
<i data-lucide="moon" class="dark:hidden w-4 h-4"></i>
<i data-lucide="sun" class="hidden dark:block w-4 h-4"></i>
</button>
<a href="https://subsplash.com/reformedwitness/give" target="_blank" class="bg-brand-900 dark:bg-brand-500 text-white px-6 py-2.5 rounded-full transition-all">Support</a>
</nav>
<button class="lg:hidden p-2" id="mobile-toggle">
<i data-lucide="menu"></i>
</button>
</div>
</header>
<main>
<section class="relative min-h-[85vh] flex items-center pt-20 overflow-hidden">
<div class="absolute inset-0 z-0">
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-brand-50 dark:to-obsidian z-10"></div>
<img src="https://images.unsplash.com/photo-1519791883288-dc8bd696e667?auto=format&fit=crop&w=2000&q=80"
class="w-full h-full object-cover opacity-20 dark:opacity-10 scale-105 hero-zoom" alt="Library">
</div>
<div class="container mx-auto px-6 relative z-20">
<div class="max-w-4xl">
<h1 class="text-6xl md:text-8xl font-serif mb-8 leading-[1.1]">
Timeless Truth. <br>
<span class="italic text-brand-500">Modern Witness.</span>
</h1>
<p class="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" class="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 <i data-lucide="chevron-down" class="w-4 h-4"></i>
</a>
</div>
</div>
</section>
<section id="ministries" class="py-24 bg-white dark:bg-neutral-900 transition-colors">
<div class="container mx-auto px-6">
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 auto-rows-[300px]">
<div class="md:col-span-8 md:row-span-2 relative group overflow-hidden rounded-[2.5rem] bento-card shadow-lg">
<img src="https://images.unsplash.com/photo-1478737270239-2f02b77fc618?auto=format&fit=crop&w=1200" class="absolute inset-0 w-full h-full object-cover opacity-80 transition-transform duration-700 group-hover:scale-105">
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>
<div class="absolute top-8 right-8 flex gap-2">
<span class="bg-brand-500 text-white text-[10px] font-bold uppercase tracking-widest px-4 py-1.5 rounded-full">Coming Soon</span>
</div>
<div class="absolute bottom-0 left-0 p-10">
<h3 class="text-4xl font-serif text-white mb-4">Pulpit Stream</h3>
<p class="text-stone-300 mb-6 max-w-md">Stream sermons and discussions from trusted Reformed pastors.</p>
<a href="https://pulpitstream.com" target="_blank" class="inline-flex items-center gap-2 text-brand-500 font-bold hover:text-white transition-colors">
Preview Page <i data-lucide="external-link" class="w-4 h-4"></i>
</a>
</div>
</div>
<div class="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 class="text-2xl font-serif mb-2">Confessions of Grace</h3>
<p class="text-stone-600 dark:text-stone-400 text-sm">Theological reflections and devotional pieces.</p>
</div>
<a href="https://confessionsofgrace.com" target="_blank" class="text-brand-600 dark:text-brand-500 font-bold flex items-center gap-2">
Visit Blog <i data-lucide="arrow-right" class="w-4 h-4"></i>
</a>
</div>
<div class="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">
<i data-lucide="share-2" class="absolute -right-4 -top-4 w-24 h-24 opacity-5"></i>
<div class="relative z-10">
<h3 class="text-2xl font-serif mb-2">Confessional.social</h3>
<p class="text-stone-400 text-sm">A decentralized space for Christian fellowship.</p>
</div>
<a href="https://confessional.social" target="_blank" class="text-brand-500 font-bold flex items-center gap-2 relative z-10">
Join Community <i data-lucide="users" class="w-4 h-4"></i>
</a>
</div>
<div class="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 class="max-w-xl text-center md:text-left">
<h3 class="text-2xl font-serif mb-2">Dead Puritan Society</h3>
<p class="text-stone-500 text-sm leading-relaxed">Engage with profound wisdom from past theologians. This initiative provides curated quotes and resources for the modern church.</p>
</div>
<div class="mt-6 md:mt-0 flex flex-col items-center md:items-end">
<span class="text-[10px] font-bold text-brand-500 uppercase tracking-widest mb-2">Archive Research</span>
<div class="px-6 py-2 bg-stone-100 dark:bg-stone-800 rounded-full text-stone-400 text-xs font-mono">LOCKED // COMING SOON</div>
</div>
</div>
</div>
</div>
</section>
<section id="labs" class="py-24 bg-brand-50 dark:bg-obsidian border-y border-stone-200 dark:border-stone-800">
<div class="container mx-auto px-6">
<div class="flex items-center gap-4 mb-12">
<h2 class="text-3xl font-serif">RWN Labs</h2>
<div class="h-px flex-grow bg-brand-500/20"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="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 class="text-xl font-serif mb-2">GBA Confession Reader</h3>
<p class="text-stone-500 text-xs font-mono mb-4">repo: gba-2lbcf</p>
<a href="https://github.com/reformed-witness/gba-2lbcf" target="_blank" class="text-brand-500 font-bold text-xs flex items-center gap-2">VIEW SOURCE <i data-lucide="github" class="w-4 h-4"></i></a>
</div>
<div class="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 class="text-xl font-serif mb-2">Konfessio</h3>
<p class="text-stone-500 text-xs font-mono mb-4">repo: konfessio</p>
<a href="https://github.com/reformed-witness/konfessio" target="_blank" class="text-brand-500 font-bold text-xs flex items-center gap-2">VIEW SOURCE <i data-lucide="github" class="w-4 h-4"></i></a>
</div>
</div>
</div>
</section>
</main>
<footer class="bg-brand-50 dark:bg-obsidian py-16 text-center border-t border-stone-200 dark:border-stone-800">
<div class="font-serif text-2xl mb-8">RWN<span class="text-brand-500">.</span></div>
<p class="text-[10px] uppercase tracking-[0.4em] text-stone-400">© 2026 Reformed Witness Network</p>
</footer>
<script src="script.js"></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-parent</artifactId>
<version>0.1.6</version>
<relativePath/>
</parent>
<groupId>net.reformedwitness</groupId>
<artifactId>rwn-website</artifactId>
<version>0.1.0</version>
<name>Reformed Witness Network</name>
<description>Reformed Witness Network — the ministry network site, on the Bennett platform.</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-bom</artifactId>
<version>0.1.6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Boot 4.1 no longer manages the raw org.testcontainers:* module versions -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.21.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Platform releases live in the Gitea Maven registry (anonymous read). Declared here so Renovate
can discover new platform versions and open a bump PR. Maven still needs this repo in
settings.xml for PARENT resolution (see .gitea/ci-settings.xml). -->
<repositories>
<repository>
<id>gitea</id>
<url>https://git.thebennett.net/api/packages/austin/maven</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-web</artifactId>
</dependency>
<dependency>
<groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-data</artifactId>
</dependency>
<!-- No starter-contact: the call to action is the booking link the live site uses, so there
is no form to deliver. No starter-security: nothing here to sign in to. -->
<!-- test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- SPA build inherited from platform-parent (node install + npm build + copy dist -> jar). -->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"description": "Bennett platform releases: our own code, already tested and Trivy-scanned before publish. Group the parent + BOM + starters into one PR and merge it automatically so security fixes reach this app without manual work. Merging main triggers the build, which re-runs tests and re-scans; if either fails no image is pushed, so a bad bump can't reach production.",
"matchPackageNames": ["net.thebennett.platform:**"],
"groupName": "bennett platform",
"automerge": true
},
{
"description": "Stay on Testcontainers 1.x. 2.0 renamed the module artifacts (postgresql, junit-jupiter), so the major bump doesn't just fail the build — its POM won't even parse. Revisit deliberately, not via a bot PR.",
"matchPackageNames": ["org.testcontainers:**"],
"matchUpdateTypes": ["major"],
"enabled": false
}
],
"vulnerabilityAlerts": {
"labels": ["security"]
}
}
-53
View File
@@ -1,53 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
// Initialize Icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Theme Engine
const themeToggle = document.getElementById('theme-toggle');
const updateIcons = () => {
// Lucide re-rendering if needed
lucide.createIcons();
};
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('rwn-v2-theme', isDark ? 'dark' : 'light');
});
// Check LocalStorage
if (localStorage.getItem('rwn-v2-theme') === 'dark') {
document.documentElement.classList.add('dark');
}
// Scroll Progress
window.addEventListener('scroll', () => {
const bar = document.getElementById('scroll-progress');
const height = document.documentElement.scrollHeight - window.innerHeight;
const scrolled = (window.scrollY / height);
bar.style.transform = `scaleX(${scrolled})`;
});
// Reveal Animation on Scroll
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('reveal-active');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.bento-card, #labs .group').forEach(el => {
el.classList.add('reveal-init');
revealObserver.observe(el);
});
// Simple Mobile Toggle
const mobileBtn = document.getElementById('mobile-toggle');
mobileBtn.addEventListener('click', () => {
alert("Mobile menu implementation: Add a slide-over panel here.");
// Usually you'd toggle a 'hidden' class on a menu div
});
});
@@ -0,0 +1,12 @@
package net.reformedwitness.rwn;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RwnApplication {
public static void main(String[] args) {
SpringApplication.run(RwnApplication.class, args);
}
}
@@ -0,0 +1,34 @@
package net.reformedwitness.rwn.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/** A published repository. */
@Entity
@Table(name = "lab")
public class Lab extends BaseEntity {
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, length = 120)
private String repo;
@Column(name = "link_url", nullable = false, length = 500)
private String linkUrl;
@Column(name = "position", nullable = false)
private int position;
protected Lab() {
// for JPA
}
public String getName() { return name; }
public String getRepo() { return repo; }
public String getLinkUrl() { return linkUrl; }
public int getPosition() { return position; }
}
@@ -0,0 +1,10 @@
package net.reformedwitness.rwn.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LabRepository extends JpaRepository<Lab, Long> {
List<Lab> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,58 @@
package net.reformedwitness.rwn.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/** One work of the network, as it appears in the bento grid. */
@Entity
@Table(name = "ministry")
public class Ministry extends BaseEntity {
/** Card treatments the grid knows how to render. */
public enum Style { FEATURE, LIGHT, DARK, OUTLINE }
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, columnDefinition = "text")
private String blurb;
@Column(name = "link_url", length = 500)
private String linkUrl;
@Column(name = "link_label", length = 80)
private String linkLabel;
@Column(length = 40)
private String badge;
@Column(nullable = false, length = 20)
private String style;
@Column(name = "image_key", length = 300)
private String imageKey;
/** Shown instead of a link when there is nothing to visit yet. */
@Column(name = "status_note", length = 120)
private String statusNote;
@Column(name = "position", nullable = false)
private int position;
protected Ministry() {
// for JPA
}
public String getName() { return name; }
public String getBlurb() { return blurb; }
public String getLinkUrl() { return linkUrl; }
public String getLinkLabel() { return linkLabel; }
public String getBadge() { return badge; }
public String getStyle() { return style; }
public String getImageKey() { return imageKey; }
public String getStatusNote() { return statusNote; }
public int getPosition() { return position; }
}
@@ -0,0 +1,10 @@
package net.reformedwitness.rwn.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MinistryRepository extends JpaRepository<Ministry, Long> {
List<Ministry> findAllByOrderByPositionAsc();
}
@@ -0,0 +1,76 @@
package net.reformedwitness.rwn.web;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import net.reformedwitness.rwn.domain.Lab;
import net.reformedwitness.rwn.domain.LabRepository;
import net.reformedwitness.rwn.domain.Ministry;
import net.reformedwitness.rwn.domain.MinistryRepository;
/**
* The network: what it runs and what it publishes.
*
* <p>Both lists were hard-coded in the page, so launching a ministry or publishing a repository meant
* editing markup and redeploying. Image URLs are assembled here too, from the bucket configured for
* the deployment, so the page never hard-codes where the photos live.
*/
@RestController
public class NetworkController {
private final MinistryRepository ministries;
private final LabRepository labs;
private final String assetBaseUrl;
public NetworkController(MinistryRepository ministries, LabRepository labs,
@Value("${site.assets.base-url:https://s3.thebennett.net/rwn}") String assetBaseUrl) {
this.ministries = ministries;
this.labs = labs;
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
}
/**
* @param style which card treatment the grid should use
* @param imageUrl absolute, or null when the card has no photo
*/
public record MinistryView(String name, String blurb, String linkUrl, String linkLabel,
String badge, String style, String imageUrl, String statusNote) {}
public record LabView(String name, String repo, String linkUrl) {}
public record Network(List<MinistryView> ministries, List<LabView> labs) {}
@GetMapping("/api/network")
@Transactional(readOnly = true)
public Network network() {
return new Network(
ministries.findAllByOrderByPositionAsc().stream().map(this::toView).toList(),
labs.findAllByOrderByPositionAsc().stream()
.map(l -> new LabView(l.getName(), l.getRepo(), l.getLinkUrl()))
.toList());
}
private MinistryView toView(Ministry m) {
return new MinistryView(m.getName(), m.getBlurb(), m.getLinkUrl(), m.getLinkLabel(),
m.getBadge(), m.getStyle(), imageUrl(m.getImageKey()), m.getStatusNote());
}
/** Each path segment is encoded so a key containing a space still fetches. */
private String imageUrl(String key) {
if (key == null || key.isBlank()) {
return null;
}
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
.collect(Collectors.joining("/"));
return assetBaseUrl + "/images/" + encoded;
}
}
+36
View File
@@ -0,0 +1,36 @@
spring:
application:
name: rwn-website
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/rwn_website}
username: ${DB_USER:rwn_website}
password: ${DB_PASSWORD:changeme}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
platform:
web:
spa:
enabled: true
data:
auditing:
enabled: true
site:
base-url: ${SITE_BASE_URL:https://reformedwitness.net}
assets:
base-url: ${ASSET_BASE_URL:https://s3.thebennett.net/rwn}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
@@ -0,0 +1,51 @@
-- The network: the ministries it runs and the code it publishes.
--
-- These were hard-coded in the page. They are the things most likely to change — a ministry launches,
-- a "coming soon" becomes live, a repository is added — and each change meant editing markup.
create table ministry (
id bigserial primary key,
name varchar(200) not null,
blurb text not null,
link_url varchar(500),
link_label varchar(80),
-- Shown as a pill on the card, e.g. "Coming Soon". Null when the ministry is simply live.
badge varchar(40),
-- Which card treatment the bento grid gives it. The layout is markup; which one applies is data.
style varchar(20) not null,
-- Optional key of a cover image in the public MinIO bucket.
image_key varchar(300),
-- For the Dead Puritan Society: a status line instead of a link.
status_note varchar(120),
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create table lab (
id bigserial primary key,
name varchar(200) not null,
repo varchar(120) not null,
link_url varchar(500) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
insert into ministry (name, blurb, link_url, link_label, badge, style, image_key, status_note, position, created_at) values
('Pulpit Stream',
'Stream sermons and discussions from trusted Reformed pastors.',
'https://pulpitstream.com', 'Preview Page', 'Coming Soon', 'FEATURE', 'pulpit.webp', null, 1, now()),
('Confessions of Grace',
'Theological reflections and devotional pieces.',
'https://confessionsofgrace.com', 'Visit Blog', null, 'LIGHT', null, null, 2, now()),
('Confessional.social',
'A decentralized space for Christian fellowship.',
'https://confessional.social', 'Join Community', null, 'DARK', null, null, 3, now()),
('Dead Puritan Society',
'Engage with profound wisdom from past theologians. This initiative provides curated quotes and resources for the modern church.',
null, null, null, 'OUTLINE', null, 'LOCKED // COMING SOON', 4, now());
insert into lab (name, repo, link_url, position, created_at) values
('GBA Confession Reader', 'gba-2lbcf', 'https://github.com/reformed-witness/gba-2lbcf', 1, now()),
('Konfessio', 'konfessio', 'https://github.com/reformed-witness/konfessio', 2, now());
@@ -0,0 +1,80 @@
package net.reformedwitness.rwn;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import net.reformedwitness.rwn.web.NetworkController;
@SpringBootTest(properties = "site.assets.base-url=https://s3.example.test/rwn")
@Testcontainers
class NetworkContentTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@Autowired
NetworkController network;
@Test
void servesEveryMinistryInOrder() {
assertThat(network.network().ministries())
.extracting(NetworkController.MinistryView::name)
.containsExactly("Pulpit Stream", "Confessions of Grace", "Confessional.social",
"Dead Puritan Society");
}
@Test
void everyMinistryHasACardTreatmentTheGridKnows() {
// An unknown style falls through to the OUTLINE card, which would quietly mis-render.
assertThat(network.network().ministries())
.extracting(NetworkController.MinistryView::style)
.containsExactly("FEATURE", "LIGHT", "DARK", "OUTLINE");
}
@Test
void onlyTheFeatureCardCarriesAPhoto() {
assertThat(network.network().ministries())
.filteredOn(m -> m.imageUrl() != null)
.singleElement()
.satisfies(m -> {
assertThat(m.name()).isEqualTo("Pulpit Stream");
assertThat(m.imageUrl()).isEqualTo("https://s3.example.test/rwn/images/pulpit.webp");
});
}
@Test
void aMinistryWithNowhereToGoOffersAStatusInstead() {
assertThat(network.network().ministries())
.filteredOn(m -> m.linkUrl() == null)
.singleElement()
.satisfies(m -> {
assertThat(m.name()).isEqualTo("Dead Puritan Society");
assertThat(m.statusNote()).isNotBlank();
});
}
@Test
void everyLinkedMinistryHasALabelForItsLink() {
// A link with no label renders as an empty clickable gap.
assertThat(network.network().ministries())
.filteredOn(m -> m.linkUrl() != null)
.allSatisfy(m -> assertThat(m.linkLabel()).isNotBlank());
}
@Test
void listsThePublishedRepositories() {
assertThat(network.network().labs())
.extracting(NetworkController.LabView::repo)
.containsExactly("gba-2lbcf", "konfessio");
}
}
-66
View File
@@ -1,66 +0,0 @@
/* Custom Blurs & Glass */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
.dark .glass {
background: rgba(18, 18, 18, 0.7);
}
/* 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);
}
/* Hero Zoom Keyframe */
@keyframes heroZoom {
0% { transform: scale(1.05); }
100% { transform: scale(1.15); }
}
.hero-zoom {
animation: heroZoom 20s infinite alternate ease-in-out;
}
/* Hidden elements for reveal observer */
.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;
}
/* Glassmorphism Header */
.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); }
/* Animation: Subtle Zoom for Hero */
@keyframes heroZoom {
0% { transform: scale(1); }
100% { transform: scale(1.1); }
}
.hero-zoom { animation: heroZoom 20s infinite alternate ease-in-out; }
/* Bento Interaction */
.bento-card { transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1); }
.bento-card:hover { transform: translateY(-8px); }
/* Custom Scroll Progress Bar */
#scroll-progress {
transform-origin: 0%;
transition: transform 0.1s linear;
}