Use R2 for public image assets

This commit is contained in:
2026-04-15 11:10:44 -04:00
parent 21c82882f4
commit ff108963a4
6 changed files with 87 additions and 9 deletions

View File

@@ -27,3 +27,8 @@ AUTH_URL=http://localhost:3000
# https://console.cloud.google.com/apis/credentials # https://console.cloud.google.com/apis/credentials
AUTH_GOOGLE_ID= AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
# Cloudflare R2 public image origin
# Use a custom domain or R2 public development URL with no trailing slash.
# Upload site assets under /images, for example /images/hero-rv-sunset.jpg.
NEXT_PUBLIC_R2_PUBLIC_URL=

View File

@@ -1,8 +1,40 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
function getR2PublicUrl(): URL | null {
const publicUrl = process.env.NEXT_PUBLIC_R2_PUBLIC_URL?.trim();
if (!publicUrl) {
return null;
}
try {
const url = new URL(publicUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") {
return null;
}
return url;
} catch {
return null;
}
}
const r2PublicUrl = getR2PublicUrl();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
images: { images: {
remotePatterns: [], remotePatterns: r2PublicUrl
? [
{
protocol: r2PublicUrl.protocol.replace(":", "") as "http" | "https",
hostname: r2PublicUrl.hostname,
port: r2PublicUrl.port,
pathname: "/**",
},
]
: [],
unoptimized: Boolean(r2PublicUrl),
}, },
}; };

View File

@@ -1,6 +1,7 @@
import Image from "next/image"; import Image from "next/image";
import SectionWrapper from "@/components/ui/SectionWrapper"; import SectionWrapper from "@/components/ui/SectionWrapper";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { assetUrl } from "@/lib/assets";
export default function FounderSection() { export default function FounderSection() {
return ( return (
@@ -10,7 +11,7 @@ export default function FounderSection() {
<div className="relative"> <div className="relative">
<div className="relative rounded-2xl overflow-hidden aspect-[4/5] bg-slate-100 shadow-xl"> <div className="relative rounded-2xl overflow-hidden aspect-[4/5] bg-slate-100 shadow-xl">
<Image <Image
src="/images/david.jpg" src={assetUrl("/images/david.jpg")}
alt="David, Founder of TechSolve Travel, working from his RV with a Starlink and Peplink setup" alt="David, Founder of TechSolve Travel, working from his RV with a Starlink and Peplink setup"
fill fill
className="object-cover object-top" className="object-cover object-top"

View File

@@ -1,12 +1,13 @@
import Image from "next/image"; import Image from "next/image";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { assetUrl } from "@/lib/assets";
export default function HeroSection() { export default function HeroSection() {
return ( return (
<section className="relative min-h-[90vh] flex items-center"> <section className="relative min-h-[90vh] flex items-center">
{/* Background image */} {/* Background image */}
<Image <Image
src="/images/hero-rv-sunset.jpg" src={assetUrl("/images/hero-rv-sunset.jpg")}
alt="RV parked at sunset with Starlink dish and enterprise networking hardware" alt="RV parked at sunset with Starlink dish and enterprise networking hardware"
fill fill
priority priority

33
src/lib/assets.ts Normal file
View File

@@ -0,0 +1,33 @@
const assetBaseUrl = getAssetBaseUrl();
function getAssetBaseUrl(): string {
const rawUrl = process.env.NEXT_PUBLIC_R2_PUBLIC_URL?.trim();
if (!rawUrl) {
return "";
}
try {
const url = new URL(rawUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") {
return "";
}
url.search = "";
url.hash = "";
return url.href.replace(/\/$/, "");
} catch {
return "";
}
}
export function assetUrl(path: string): string {
if (!assetBaseUrl) {
return path;
}
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${assetBaseUrl}${normalizedPath}`;
}

View File

@@ -1,4 +1,5 @@
import type { PricingTier, ProcessStep, TrustLogo } from "@/types"; import type { PricingTier, ProcessStep, TrustLogo } from "@/types";
import { assetUrl } from "@/lib/assets";
export const PROCESS_STEPS: ProcessStep[] = [ export const PROCESS_STEPS: ProcessStep[] = [
{ {
@@ -98,34 +99,39 @@ export const PRICING_TIERS: PricingTier[] = [
]; ];
export const TRUST_LOGOS: TrustLogo[] = [ export const TRUST_LOGOS: TrustLogo[] = [
{ name: "Peplink", src: "/images/logo-peplink.svg", width: 120, height: 40 }, {
name: "Peplink",
src: assetUrl("/images/logo-peplink.svg"),
width: 120,
height: 40,
},
{ {
name: "GL.iNet", name: "GL.iNet",
src: "/images/logo-gliinet.svg", src: assetUrl("/images/logo-gliinet.svg"),
width: 100, width: 100,
height: 40, height: 40,
}, },
{ {
name: "Ubiquiti UniFi", name: "Ubiquiti UniFi",
src: "/images/logo-ubiquiti-unifi.svg", src: assetUrl("/images/logo-ubiquiti-unifi.svg"),
width: 110, width: 110,
height: 40, height: 40,
}, },
{ {
name: "WireGuard", name: "WireGuard",
src: "/images/logo-wireguard.svg", src: assetUrl("/images/logo-wireguard.svg"),
width: 120, width: 120,
height: 40, height: 40,
}, },
{ {
name: "Fortinet", name: "Fortinet",
src: "/images/logo-fortinet.svg", src: assetUrl("/images/logo-fortinet.svg"),
width: 110, width: 110,
height: 40, height: 40,
}, },
{ {
name: "Starlink", name: "Starlink",
src: "/images/logo-starlink.svg", src: assetUrl("/images/logo-starlink.svg"),
width: 110, width: 110,
height: 40, height: 40,
}, },