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
@@ -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");
}
}