findAllByOrderByPositionAsc();
+}
diff --git a/src/main/java/net/reformedwitness/rwn/web/NetworkController.java b/src/main/java/net/reformedwitness/rwn/web/NetworkController.java
new file mode 100644
index 0000000..7a6f1d6
--- /dev/null
+++ b/src/main/java/net/reformedwitness/rwn/web/NetworkController.java
@@ -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.
+ *
+ * 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 ministries, List 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;
+ }
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
new file mode 100644
index 0000000..27f3d79
--- /dev/null
+++ b/src/main/resources/application.yaml
@@ -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
diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql
new file mode 100644
index 0000000..491bc39
--- /dev/null
+++ b/src/main/resources/db/migration/V1__init.sql
@@ -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());
diff --git a/src/test/java/net/reformedwitness/rwn/NetworkContentTest.java b/src/test/java/net/reformedwitness/rwn/NetworkContentTest.java
new file mode 100644
index 0000000..7d99f41
--- /dev/null
+++ b/src/test/java/net/reformedwitness/rwn/NetworkContentTest.java
@@ -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");
+ }
+}
diff --git a/style.css b/style.css
deleted file mode 100644
index 0cff86b..0000000
--- a/style.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file