Archived
Rebuild on the Bennett platform: Spring Boot + Vite/React
Same site — glass header, bento grid, hero drift, dark mode — with three things fixed on the way: - Tailwind and lucide came from CDNs on every page load, and the fonts from Google. All are now built in or self-hosted, so the site owes nothing to third parties at runtime. - The hero and bento photographs were hot-linked from Unsplash. They are re-encoded to webp and served from the MinIO bucket with a year-long cache. - The ministries and labs were hard-coded in the markup, so launching a ministry meant editing HTML. They now come from /api/network. The mobile menu button also opens something now; it did nothing before.
This commit is contained in:
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user