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:
+7
-4
@@ -1,4 +1,7 @@
|
||||
.git
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
README.md
|
||||
target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.git/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.DS_Store
|
||||
+36
-4
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
Generated
+1388
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user