Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f56060d6b | |||
| 10bd6848ef | |||
| b26103b7b1 | |||
| 83743cc78b | |||
| ee153cf9ef | |||
| 88860bc42a | |||
| c5cc28b36b | |||
| 73b8299f1a | |||
| b4f1c1dbdb | |||
| 8a71aecb4e | |||
| cc4a4b4d63 | |||
| 0c328057c8 | |||
| 5562db992b | |||
| 287fa72462 | |||
| 50459b7234 | |||
| 36594d5e16 | |||
| 0e9d112c44 | |||
| 063f5d2a0e | |||
| 6e8b360398 | |||
| 770d2357ac | |||
| f07d8fecfb | |||
| dcfce9dc24 | |||
| 738cc2a412 | |||
| bac22950ba | |||
| 85af1a29ad | |||
| 85e8a298fd | |||
| a252167052 | |||
| 53408ad124 | |||
| d5f15486d8 | |||
| afb80aaf2a | |||
| 3a9d11cf40 | |||
| 2653d95605 | |||
| 7772bfd6a6 | |||
| 74650a39b3 | |||
| c46d765759 | |||
| d95ed9cd8d | |||
| 4b01d01133 | |||
| e93840c25f | |||
| 5b401671db | |||
| f156257573 | |||
| 057c856df7 |
@@ -0,0 +1,23 @@
|
||||
# Copy this file to .env and fill in your real values.
|
||||
# Never commit .env to version control.
|
||||
|
||||
# Resend API key for the contact form
|
||||
# 1. Sign up free at https://resend.com
|
||||
# 2. Go to API Keys and create a new key
|
||||
# 3. Paste it below
|
||||
RESEND_API_KEY=re_your_api_key_here
|
||||
|
||||
# Optional: use a separate key for Contacts/Segments if your main key is send-only
|
||||
RESEND_CONTACTS_API_KEY=
|
||||
|
||||
# Email address that receives contact form submissions
|
||||
RESEND_TO=hello@versebyversewithnate.us
|
||||
|
||||
# Optional: automatically add contact form submitters to a Resend Segment
|
||||
# Create a Segment in Resend and paste its ID here if you want new contacts grouped.
|
||||
RESEND_SEGMENT_ID=
|
||||
|
||||
# "From" address shown on received emails.
|
||||
# During testing you can leave this as-is (uses Resend's shared domain).
|
||||
# For production: verify your own domain at resend.com/domains and change this.
|
||||
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
|
||||
@@ -17,6 +17,10 @@ dist-ssr
|
||||
# the image default and is preserved here for the Docker build context only.
|
||||
# Uncomment the line below if you do NOT want to track live data in git.
|
||||
# data/admin-content.json
|
||||
data/backups/
|
||||
data/hit-stats.json
|
||||
data/visitor-stats.json
|
||||
data/contact-submissions.json
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -18,6 +18,7 @@ RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server.js ./server.js
|
||||
COPY --from=build /app/A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf
|
||||
|
||||
# Copy seed data to a separate directory so the entrypoint can seed /app/data
|
||||
# only when no live data exists yet — upgrades never overwrite existing data.
|
||||
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 651 KiB |
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||
<path d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||
<path d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||
<path d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||
<path d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||
<path d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||
<path d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||
<path d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||
<path d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||
<path d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||
<path d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||
<path d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||
<path d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||
<path d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||
<path d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||
<path d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M25.6619,8.6162c-.0017-.2948-.0051-.5895-.0131-.8843-.0174-.6422-.0552-1.29-.1694-1.9251-.1158-.6443-.3048-1.2439-.6028-1.8293-.2929-.5753-.6755-1.1018-1.132-1.5583-.4565-.4565-.983-.8392-1.5583-1.132-.5854-.298-1.185-.487-1.8293-.6028-.6351-.1142-1.2829-.152-1.9251-.1694-.2947-.008-.5895-.0114-.8843-.0131-.35-.0022-.7-.0021-1.05-.0021h-6.8316c-.35,0-.7-.0001-1.05.0021-.2948.0017-.5895.0051-.8842.0131-.6422.0174-1.29.0552-1.9251.1694-.6443.1158-1.2439.3048-1.8293.6028-.5753.2929-1.1018.6755-1.5583,1.132s-.8392.983-1.132,1.5583c-.298.5854-.487,1.185-.6028,1.8293-.1142.6351-.152,1.2829-.1694,1.9251-.008.2947-.0114.5895-.0131.8843-.0022.35-.0021.7-.0021,1.05v6.8316c0,.35-.0001.7.0021,1.05.0017.2948.0051.5895.0131.8843.0174.6422.0552,1.29.1694,1.9251.1158.6443.3048,1.2439.6028,1.8293.2929.5753.6755,1.1018,1.132,1.5583s.983.8392,1.5583,1.132c.5854.298,1.185.487,1.8293.6028.6351.1142,1.2829.152,1.9251.1694.2947.008.5894.0114.8842.0131.35.0022.7.0021,1.05.0021h6.8316c.35,0,.7.0001,1.05-.0021.2948-.0017.5895-.0051.8843-.0131.6422-.0174,1.29-.0552,1.9251-.1694.6443-.1158,1.2439-.3048,1.8293-.6028.5753-.2929,1.1018-.6755,1.5583-1.132.4565-.4565.8392-.983,1.132-1.5583.298-.5854.487-1.185.6028-1.8293.1142-.6351.152-1.2829.1694-1.9251.008-.2947.0114-.5895.0131-.8843.0022-.35.0021-.7.0021-1.05v-6.8316c0-.35.0001-.7-.0021-1.05ZM14.7462,21.7244c-.3066,1.028-.7664,1.4337-1.6862,1.4337-.9287,0-1.3886-.4057-1.6862-1.4337-.2164-.7394-.6582-3.3904-.6582-5.0224,0-1.0189.8927-2.0829,2.3444-2.0829s2.3354,1.064,2.3354,2.0829c0,1.6321-.4418,4.283-.6492,5.0224ZM10.8509,11.6344c0-1.1993.9829-2.1821,2.2092-2.1821,1.2083,0,2.1911.9829,2.1911,2.1821,0,1.2263-.9829,2.2182-2.1911,2.2182-1.2263,0-2.2092-.9919-2.2092-2.2182ZM13.0511,7.1801c-2.5789,0-4.6708,2.0919-4.6708,4.6618,0,1.4427.6582,2.7321,1.7042,3.5797-.1894.2976-.3066.6222-.3697.9739-1.4157-1.0189-2.3264-2.6871-2.3264-4.5536,0-3.1199,2.5338-5.6627,5.6627-5.6627,3.1199,0,5.6627,2.5428,5.6627,5.6627,0,1.8665-.9288,3.5256-2.3173,4.5445-.0631-.3787-.1894-.6492-.3877-.9738,1.0369-.8386,1.7042-2.137,1.7042-3.5707,0-2.5698-2.0919-4.6618-4.6618-4.6618ZM16.0447,20.6153c.0541-.3697.1172-.7664.1623-1.1632,2.8854-1.2173,4.9142-4.0666,4.9142-7.3939,0-4.4544-3.6158-8.0611-8.0612-8.0611-4.4634,0-8.0792,3.6068-8.0792,8.0611,0,3.3273,2.0288,6.1856,4.9233,7.3939.0451.3877.1082.7935.1623,1.1542-3.5437-1.2263-6.0774-4.5806-6.0774-8.5481C3.989,7.0538,8.0467,2.9962,13.0601,2.9962c5.0044,0,9.053,4.0576,9.053,9.062,0,3.9585-2.5427,7.3218-6.0684,8.5571Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path class="st0" d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||
<path class="st0" d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||
<path class="st0" d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||
<path class="st0" d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||
<path class="st0" d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||
<path class="st0" d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||
<path class="st0" d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||
<path class="st0" d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||
<path class="st0" d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||
<path class="st0" d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||
<path class="st0" d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||
<path class="st0" d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||
<path class="st0" d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||
<path class="st0" d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||
<path class="st0" d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||
<path class="st0" d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path class="st0" d="M25.6619,8.6162c-.0017-.2948-.0051-.5895-.0131-.8843-.0174-.6422-.0552-1.29-.1694-1.9251-.1158-.6443-.3048-1.2439-.6028-1.8293-.2929-.5753-.6755-1.1018-1.132-1.5583-.4565-.4565-.983-.8392-1.5583-1.132-.5854-.298-1.185-.487-1.8293-.6028-.6351-.1142-1.2829-.152-1.9251-.1694-.2947-.008-.5895-.0114-.8843-.0131-.35-.0022-.7-.0021-1.05-.0021h-6.8316c-.35,0-.7-.0001-1.05.0021-.2948.0017-.5895.0051-.8842.0131-.6422.0174-1.29.0552-1.9251.1694-.6443.1158-1.2439.3048-1.8293.6028-.5753.2929-1.1018.6755-1.5583,1.132s-.8392.983-1.132,1.5583c-.298.5854-.487,1.185-.6028,1.8293-.1142.6351-.152,1.2829-.1694,1.9251-.008.2947-.0114.5895-.0131.8843-.0022.35-.0021.7-.0021,1.05v6.8316c0,.35-.0001.7.0021,1.05.0017.2948.0051.5895.0131.8843.0174.6422.0552,1.29.1694,1.9251.1158.6443.3048,1.2439.6028,1.8293.2929.5753.6755,1.1018,1.132,1.5583s.983.8392,1.5583,1.132c.5854.298,1.185.487,1.8293.6028.6351.1142,1.2829.152,1.9251.1694.2947.008.5894.0114.8842.0131.35.0022.7.0021,1.05.0021h6.8316c.35,0,.7.0001,1.05-.0021.2948-.0017.5895-.0051.8843-.0131.6422-.0174,1.29-.0552,1.9251-.1694.6443-.1158,1.2439-.3048,1.8293-.6028.5753-.2929,1.1018-.6755,1.5583-1.132.4565-.4565.8392-.983,1.132-1.5583.298-.5854.487-1.185.6028-1.8293.1142-.6351.152-1.2829.1694-1.9251.008-.2947.0114-.5895.0131-.8843.0022-.35.0021-.7.0021-1.05v-6.8316c0-.35.0001-.7-.0021-1.05ZM14.7462,21.7244c-.3066,1.028-.7664,1.4337-1.6862,1.4337-.9287,0-1.3886-.4057-1.6862-1.4337-.2164-.7394-.6582-3.3904-.6582-5.0224,0-1.0189.8927-2.0829,2.3444-2.0829s2.3354,1.064,2.3354,2.0829c0,1.6321-.4418,4.283-.6492,5.0224ZM10.8509,11.6344c0-1.1993.9829-2.1821,2.2092-2.1821,1.2083,0,2.1911.9829,2.1911,2.1821,0,1.2263-.9829,2.2182-2.1911,2.2182-1.2263,0-2.2092-.9919-2.2092-2.2182ZM13.0511,7.1801c-2.5789,0-4.6708,2.0919-4.6708,4.6618,0,1.4427.6582,2.7321,1.7042,3.5797-.1894.2976-.3066.6222-.3697.9739-1.4157-1.0189-2.3264-2.6871-2.3264-4.5536,0-3.1199,2.5338-5.6627,5.6627-5.6627,3.1199,0,5.6627,2.5428,5.6627,5.6627,0,1.8665-.9288,3.5256-2.3173,4.5445-.0631-.3787-.1894-.6492-.3877-.9738,1.0369-.8386,1.7042-2.137,1.7042-3.5707,0-2.5698-2.0919-4.6618-4.6618-4.6618ZM16.0447,20.6153c.0541-.3697.1172-.7664.1623-1.1632,2.8854-1.2173,4.9142-4.0666,4.9142-7.3939,0-4.4544-3.6158-8.0611-8.0612-8.0611-4.4634,0-8.0792,3.6068-8.0792,8.0611,0,3.3273,2.0288,6.1856,4.9233,7.3939.0451.3877.1082.7935.1623,1.1542-3.5437-1.2263-6.0774-4.5806-6.0774-8.5481C3.989,7.0538,8.0467,2.9962,13.0601,2.9962c5.0044,0,9.053,4.0576,9.053,9.062,0,3.9585-2.5427,7.3218-6.0684,8.5571Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -50,13 +50,26 @@ docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Admin auth:
|
||||
|
||||
- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints.
|
||||
- Without `ADMIN_PASSWORD`, admin login is disabled until configured.
|
||||
- Optional: set `CACHE_PURGE_WEBHOOK_URL` to enable one-click cache purge from Admin Operations.
|
||||
- Optional: set `DEPLOY_WEBHOOK_URL` to enable one-click deploy trigger from Admin Operations.
|
||||
- Optional: set `BUILD_COMMIT`, `BUILD_NUMBER`, and `DEPLOYED_AT` to display deployment status in Admin.
|
||||
|
||||
The app will be available at `http://localhost:4173`.
|
||||
|
||||
Persistent admin saves:
|
||||
|
||||
- Admin updates are written to `data/admin-content.json`.
|
||||
- Admin drafts are written to `data/admin-content-draft.json`.
|
||||
- Built-in page hit stats are written to `data/hit-stats.json`.
|
||||
- Detailed visitor analytics are written to `data/visitor-stats.json`.
|
||||
- Backup snapshots are written to `data/backups/`.
|
||||
- Uploaded admin assets are stored in `data/uploads/` and served at `/uploads/*`.
|
||||
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
|
||||
- This keeps your edits after container restarts/rebuilds.
|
||||
- This keeps all admin-managed data after container restarts/rebuilds/updates.
|
||||
|
||||
## Where to edit content
|
||||
|
||||
@@ -67,32 +80,40 @@ Persistent admin saves:
|
||||
|
||||
## Routes
|
||||
|
||||
- Portfolio home: `/`
|
||||
- Project details: `/projects/:slug`
|
||||
- Site home: `/`
|
||||
- Start Here: `/start-here`
|
||||
- Questions: `/questions`
|
||||
- Privacy: `/privacy`
|
||||
- Terms: `/terms`
|
||||
- Admin editor: `/admin`
|
||||
- Admin draft preview: `/preview`
|
||||
|
||||
## Admin editing
|
||||
|
||||
- Open `/admin` to edit project card and featured-page content.
|
||||
- Use **Global Settings** to update theme, home headers, and quick tips.
|
||||
- Use **Project Settings** to update project-specific fields and README URL.
|
||||
- Use **Create new entry** to add a new project directly from Admin.
|
||||
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
|
||||
- Click **Save project** to apply updates instantly.
|
||||
- Open `/admin` to edit homepage content, links, archived studies, Q&A moderation, and analytics.
|
||||
- Use **Operations** to manage SEO metadata, sitemap paths, legal page text, redirect rules, and podcast highlights.
|
||||
- Use **Operations** to upload/delete hosted image assets for use in content and social cards.
|
||||
- Use **Save Draft**, **Preview Draft**, and **Publish Draft** for a safer publish workflow.
|
||||
- Saved edits are written to `data/admin-content.json` through the API server.
|
||||
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
|
||||
- Use **Remove project** to delete the selected project from your local Admin data.
|
||||
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
|
||||
|
||||
Scan notes:
|
||||
|
||||
- URL scanning is best-effort and depends on site accessibility.
|
||||
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
|
||||
- Always review scanned fields before saving.
|
||||
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
|
||||
- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question.
|
||||
- Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner.
|
||||
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
|
||||
- Admin also supports restoring from a backup snapshot from `/admin`.
|
||||
- The server creates startup + daily backup snapshots and retains recent backups automatically.
|
||||
- Operations can trigger optional **Purge Cache** and **Trigger Deploy** webhooks.
|
||||
|
||||
Deployment note:
|
||||
|
||||
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`.
|
||||
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/`.
|
||||
|
||||
Titus study download gate:
|
||||
|
||||
- The site now gates Titus study downloads behind a name/email form.
|
||||
- Default source file is `A_Study_of_Titus.pdf` in the project root.
|
||||
- Override source path with `TITUS_STUDY_FILE` (relative to project root or absolute).
|
||||
- Override downloaded filename with `TITUS_STUDY_DOWNLOAD_NAME`.
|
||||
- When the form checkbox is left enabled (default), contacts are synced to Resend using the same contact sync flow as the contact form.
|
||||
|
||||
Useful container commands:
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"siteContent": {
|
||||
"eyebrow": "A Journey Through Scripture",
|
||||
"heroTagline": "Exploring God's Word one verse at a time",
|
||||
"startHereHeading": "New Here? Start Here",
|
||||
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
|
||||
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
|
||||
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
|
||||
"startHereStep1Cta": "Open Episodes",
|
||||
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
|
||||
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
|
||||
"startHereStep2Cta": "Go to Q&A",
|
||||
"startHereStep3Title": "Step 3: Ask Nate Directly",
|
||||
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
|
||||
"startHereStep3Cta": "Submit a Question",
|
||||
"aboutShowHeading": "Depth. Clarity. Application.",
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
|
||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||
"shareHeading": "Help one more person hear the Word this week.",
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
"customLinks": [
|
||||
{
|
||||
"id": "molso9ow",
|
||||
"label": "test",
|
||||
"url": "/uploads/discussion_questions_post-1777565348551.png",
|
||||
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
|
||||
"placement": "resources"
|
||||
}
|
||||
],
|
||||
"customBlocks": [],
|
||||
"archivedSeries": [],
|
||||
"redirects": [
|
||||
{
|
||||
"id": "spotify",
|
||||
"path": "/spotify",
|
||||
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "apple",
|
||||
"path": "/apple",
|
||||
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "amazon",
|
||||
"path": "/amazon",
|
||||
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
|
||||
"statusCode": 301
|
||||
}
|
||||
],
|
||||
"podcastFeaturedLinks": [
|
||||
{
|
||||
"id": "sample-highlight-1",
|
||||
"title": "Introduction to Titus",
|
||||
"episodeNumber": "2",
|
||||
"summary": "A practical walkthrough of context, structure, and application for this study.",
|
||||
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
|
||||
"embedUrl": "",
|
||||
"showNotes": "",
|
||||
"discussionQuestions": [
|
||||
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
|
||||
"2. The letter’s core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
|
||||
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seo": {
|
||||
"title": "Verse by Verse with Nate",
|
||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
"ogTitle": "Verse by Verse with Nate",
|
||||
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
|
||||
"ogImage": "/images/podcast-art.jpeg",
|
||||
"canonicalUrl": "https://versebyversewithnate.us/",
|
||||
"robotsPolicy": "index,follow",
|
||||
"sitemapPaths": [
|
||||
"/",
|
||||
"/start-here",
|
||||
"/questions",
|
||||
"/privacy",
|
||||
"/terms"
|
||||
]
|
||||
},
|
||||
"legal": {
|
||||
"privacyTitle": "Privacy Policy",
|
||||
"privacyBody": [
|
||||
"We respect your privacy and collect limited data to operate and improve this site.",
|
||||
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
|
||||
"Contact form details are used only to respond to your message and ministry communication requests."
|
||||
],
|
||||
"termsTitle": "Terms",
|
||||
"termsBody": [
|
||||
"Content on this site is for informational and ministry purposes.",
|
||||
"External links are provided for convenience and are subject to third-party policies.",
|
||||
"By using this site, you agree to lawful use and respectful communication."
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-04-30T18:06:00.013Z"
|
||||
}
|
||||
@@ -1,400 +1,112 @@
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"slug": "weather",
|
||||
"title": "NeCloud Weather",
|
||||
"domain": "weather.necloud.us",
|
||||
"url": "https://weather.necloud.us",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/weather/main/README.md",
|
||||
"category": "Weather",
|
||||
"access": "Public",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Weather dashboard with hourly forecasts, radar, and practical daily details.",
|
||||
"details": "The homepage highlights Open-Meteo and RainViewer data, current conditions, high/low, daylight windows, and quick bring-along suggestions.",
|
||||
"features": [
|
||||
"Hourly forecast",
|
||||
"Radar view",
|
||||
"Wind and atmosphere panel"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "Fast weather checks with useful details at a glance.",
|
||||
"problem": "Most weather tools either feel too basic or too noisy when you only want quick, actionable updates.",
|
||||
"solution": "NeCloud Weather combines clean current conditions, practical recommendation blocks, and rapid forecast scanning in one view.",
|
||||
"stack": [
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Open-Meteo",
|
||||
"RainViewer"
|
||||
],
|
||||
"highlights": [
|
||||
"Location search",
|
||||
"Hero weather card",
|
||||
"24-hour timeline",
|
||||
"Radar shortcut"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add severe weather alerts",
|
||||
"Save favorite locations",
|
||||
"Add sunrise trend charts"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the weather project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "capsule",
|
||||
"title": "Capsule",
|
||||
"domain": "capsule.necloud.us",
|
||||
"url": "https://capsule.necloud.us",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/capsule/main/README.md",
|
||||
"category": "Finance",
|
||||
"access": "Mixed",
|
||||
"iconUrl": "https://capsule.necloud.us/images/capsule-logo.svg",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Smart envelope budgeting app focused on planning and income allocation.",
|
||||
"details": "The site presents guided setup, envelope-based spending controls, and paycheck auto-allocation to keep budgets structured.",
|
||||
"features": [
|
||||
"Envelope budgeting",
|
||||
"Income auto-allocation",
|
||||
"Offline-ready data"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "A practical budgeting workflow built around envelope control.",
|
||||
"problem": "Traditional budget tools can feel rigid or overwhelming for everyday spending decisions.",
|
||||
"solution": "Capsule uses guided setup and smart allocation so users can organize spending with clear envelope categories.",
|
||||
"stack": [
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Local-first storage"
|
||||
],
|
||||
"highlights": [
|
||||
"Guided setup wizard",
|
||||
"Envelope categories",
|
||||
"Auto-allocation rules"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add recurring bill templates",
|
||||
"Import transaction feeds",
|
||||
"Monthly budget snapshots"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the capsule project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "nexusbible",
|
||||
"title": "Nexus Bible",
|
||||
"domain": "nexusbible.necloud.us",
|
||||
"url": "https://nexusbible.necloud.us",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md",
|
||||
"category": "Study",
|
||||
"access": "Mixed",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Bible reader for reading, comparison, and study using Free Use Bible API data.",
|
||||
"details": "The app exposes navigation, reader controls, bookmarks, and daily-focus tooling to support regular scripture study.",
|
||||
"features": [
|
||||
"Reader modes",
|
||||
"Bookmarks",
|
||||
"Daily focus workflow"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "Scripture reading designed for depth, rhythm, and clarity.",
|
||||
"problem": "Switching between apps for reading, comparison, and planning breaks study flow.",
|
||||
"solution": "Nexus Bible unifies reader tools, bookmarks, and daily focus controls for a single focused study experience.",
|
||||
"stack": [
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Free Use Bible API"
|
||||
],
|
||||
"highlights": [
|
||||
"Reader mode",
|
||||
"Passage navigation",
|
||||
"Bookmarks",
|
||||
"Daily focus entry points"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add multi-translation compare view",
|
||||
"Personal note collections",
|
||||
"Reading streak tracking"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the nexusbible project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "prayer",
|
||||
"title": "Haver",
|
||||
"domain": "prayer.necloud.us",
|
||||
"url": "https://prayer.necloud.us",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/haver/main/README.md",
|
||||
"category": "Faith",
|
||||
"access": "Mixed",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Prayer journal app for daily entries, requests, and answered-prayer tracking.",
|
||||
"details": "Landing content emphasizes a minimalist prayer workflow with dashboards, calendar history, and Google Calendar reminder sync.",
|
||||
"features": [
|
||||
"Prayer journal",
|
||||
"Prayer dashboard",
|
||||
"Calendar integration"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "A calm daily prayer workflow with meaningful tracking.",
|
||||
"problem": "Prayer journaling often lives across scattered notes and reminders, making consistency difficult.",
|
||||
"solution": "Haver centralizes requests, daily entries, answered prayers, and reminder sync into one simple interface.",
|
||||
"stack": [
|
||||
"React",
|
||||
"Firebase",
|
||||
"Google Calendar integration"
|
||||
],
|
||||
"highlights": [
|
||||
"Prayer composer",
|
||||
"Answered prayer tracking",
|
||||
"Calendar view",
|
||||
"Reminder sync"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add shared prayer groups",
|
||||
"Voice prayer entry",
|
||||
"Weekly summary digest"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the prayer project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "skywatch",
|
||||
"title": "Skywatch",
|
||||
"domain": "skywatch.necloud.us",
|
||||
"url": "https://skywatch.necloud.us",
|
||||
"category": "Sky and Space",
|
||||
"access": "Public",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Skywatch project in your NeCloud stack.",
|
||||
"details": "Live page content could not be scanned automatically because the endpoint responded with 401; this entry is currently a manual placeholder.",
|
||||
"features": [
|
||||
"Manual metadata fallback",
|
||||
"Live endpoint available via domain"
|
||||
],
|
||||
"scanSource": "manual",
|
||||
"featured": {
|
||||
"headline": "Skywatch project overview placeholder.",
|
||||
"problem": "Automated scanning could not access this project because the endpoint returned 401.",
|
||||
"solution": "This featured page is intentionally editable so you can replace placeholder copy with your exact app details.",
|
||||
"stack": [
|
||||
"Update manually"
|
||||
],
|
||||
"highlights": [
|
||||
"Placeholder content",
|
||||
"Manual metadata fallback"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add full description",
|
||||
"Add stack details",
|
||||
"Add feature highlights"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the skywatch project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "spellinghub",
|
||||
"title": "Spelling Hub",
|
||||
"domain": "spellinghub.necloud.us",
|
||||
"url": "https://spellinghub.necloud.us",
|
||||
"readmeUrl": "https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md",
|
||||
"category": "Education",
|
||||
"access": "Mixed",
|
||||
"ownership": "built",
|
||||
"status": "Live",
|
||||
"summary": "Modern spelling practice application with account-based learning flows.",
|
||||
"details": "Homepage messaging presents a clean practice platform with login and registration for user progress.",
|
||||
"features": [
|
||||
"Practice workflows",
|
||||
"User auth",
|
||||
"Simple home dashboard"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "Focused spelling practice with lightweight account flow.",
|
||||
"problem": "Many spelling tools are cluttered and do not support structured, repeatable daily practice.",
|
||||
"solution": "Spelling Hub keeps onboarding simple and centers the product on practical, repeatable learning loops.",
|
||||
"stack": [
|
||||
"React",
|
||||
"TypeScript",
|
||||
"Auth-backed workflow"
|
||||
],
|
||||
"highlights": [
|
||||
"Login/register flow",
|
||||
"Practice-first design",
|
||||
"Simple navigation"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add custom word lists",
|
||||
"Teacher/student modes",
|
||||
"Progress analytics dashboard"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the spellinghub project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "mealie",
|
||||
"title": "Mealie",
|
||||
"domain": "mealie.necloud.us",
|
||||
"url": "https://mealie.necloud.us",
|
||||
"category": "Self-hosted Tools",
|
||||
"access": "Mixed",
|
||||
"ownership": "hosted",
|
||||
"status": "Live",
|
||||
"summary": "Self-hosted recipe manager with categories, tags, and kitchen planning tools.",
|
||||
"details": "Scanned content confirms a full Mealie interface with home feed, recipe browsing, and account login flow.",
|
||||
"features": [
|
||||
"Recipe library",
|
||||
"Tags and categories",
|
||||
"User login"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "Recipe organization and planning through your self-hosted stack.",
|
||||
"problem": "Recipe collections get scattered across browser bookmarks and notes without centralized organization.",
|
||||
"solution": "Your Mealie instance provides structured categories, tags, and searchable recipes for day-to-day meal planning.",
|
||||
"stack": [
|
||||
"Mealie",
|
||||
"Self-hosted deployment"
|
||||
],
|
||||
"highlights": [
|
||||
"Recipe catalog",
|
||||
"Category and tag views",
|
||||
"Account login"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add meal plan snapshots",
|
||||
"Import bookmark recipes",
|
||||
"Shopping list workflows"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the mealie project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "music",
|
||||
"title": "Swing Music",
|
||||
"domain": "music.necloud.us",
|
||||
"url": "https://music.necloud.us",
|
||||
"category": "Self-hosted Tools",
|
||||
"access": "Mixed",
|
||||
"ownership": "hosted",
|
||||
"status": "Live",
|
||||
"summary": "Self-hosted music streaming interface with folders, playlists, and favorites.",
|
||||
"details": "Homepage scan identifies Swing Music with library navigation, playlists, favorites, and guest/standard access options.",
|
||||
"features": [
|
||||
"Library browsing",
|
||||
"Playlists",
|
||||
"Guest mode"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "Swing Music streaming from your own infrastructure.",
|
||||
"problem": "Centralized music services can limit control over personal libraries and hosting preferences.",
|
||||
"solution": "Your Swing Music instance provides library browsing, playlists, and favorites with local hosting control.",
|
||||
"stack": [
|
||||
"Swing Music",
|
||||
"Self-hosted deployment"
|
||||
],
|
||||
"highlights": [
|
||||
"Library navigation",
|
||||
"Playlist support",
|
||||
"Favorites",
|
||||
"Guest mode"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Improve mobile now-playing UI",
|
||||
"Add listening history",
|
||||
"Playlist sharing links"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the music project."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "seafile",
|
||||
"title": "Seafile Instance",
|
||||
"domain": "seafile.necloud.us",
|
||||
"url": "https://seafile.necloud.us",
|
||||
"category": "Self-hosted Tools",
|
||||
"access": "Login required",
|
||||
"iconUrl": "https://seafile.necloud.us/media/custom/mylogo.png",
|
||||
"ownership": "hosted",
|
||||
"status": "Live",
|
||||
"summary": "Your self-hosted Seafile instance for secure sync, sharing, and account-based access.",
|
||||
"details": "Scanned landing view exposes the Seafile login portal with account signup and password reset features.",
|
||||
"features": [
|
||||
"File sync platform",
|
||||
"Account login",
|
||||
"Password recovery"
|
||||
],
|
||||
"scanSource": "scanned",
|
||||
"featured": {
|
||||
"headline": "A private file sync and sharing system you host yourself.",
|
||||
"problem": "Cloud storage options often reduce control over data location and account governance.",
|
||||
"solution": "Your Seafile instance gives you direct ownership of file sync, sharing access, and user account management.",
|
||||
"stack": [
|
||||
"Seafile",
|
||||
"Self-hosted deployment"
|
||||
],
|
||||
"highlights": [
|
||||
"Secure login",
|
||||
"Account management",
|
||||
"Password reset flow"
|
||||
],
|
||||
"nextSteps": [
|
||||
"Add team spaces",
|
||||
"Automated backup checks",
|
||||
"Storage usage dashboard"
|
||||
],
|
||||
"updateNote": "Edit this content in src/data/projects.ts under the seafile project."
|
||||
}
|
||||
}
|
||||
],
|
||||
"siteContent": {
|
||||
"theme": "sand",
|
||||
"homeEyebrow": "Siteforge",
|
||||
"homeTitle": "Sites and Apps I Build",
|
||||
"homeIntro": "Live links generated from your domain list. Use the Admin panel to update cards and About pages without editing code.",
|
||||
"projectEyebrow": "Project Details",
|
||||
"quickTipsTitle": "Quick edit tips",
|
||||
"quickTipsBody": "Open /admin to update card and featured page content. Changes are saved in your project data file via the API server.",
|
||||
"downloadsTitle": "Downloads",
|
||||
"downloadsIntro": "Add shared files, installers, or documents here. Seafile share links work well for this section.",
|
||||
"downloads": [
|
||||
"eyebrow": "A Journey Through Scripture",
|
||||
"heroTagline": "Exploring God's Word one verse at a time",
|
||||
"startHereHeading": "New Here? Start Here",
|
||||
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
|
||||
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
|
||||
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
|
||||
"startHereStep1Cta": "Open Episodes",
|
||||
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
|
||||
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
|
||||
"startHereStep2Cta": "Go to Q&A",
|
||||
"startHereStep3Title": "Step 3: Ask Nate Directly",
|
||||
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
|
||||
"startHereStep3Cta": "Submit a Question",
|
||||
"aboutShowHeading": "Depth. Clarity. Application.",
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
|
||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||
"shareHeading": "Help one more person hear the Word this week.",
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
"customLinks": [
|
||||
{
|
||||
"title": "Shared File",
|
||||
"url": "https://seafile.necloud.us",
|
||||
"description": "Replace this with a direct Seafile share link or another download URL."
|
||||
"id": "molso9ow",
|
||||
"label": "test",
|
||||
"url": "/uploads/discussion_questions_post-1777565348551.png",
|
||||
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
|
||||
"placement": "resources"
|
||||
}
|
||||
],
|
||||
"showSidebar": true,
|
||||
"showWeatherWidget": true,
|
||||
"showMusicBar": true,
|
||||
"homeSections": [
|
||||
"customBlocks": [],
|
||||
"archivedSeries": [],
|
||||
"redirects": [
|
||||
{
|
||||
"id": "summary",
|
||||
"placement": "main"
|
||||
"id": "spotify",
|
||||
"path": "/spotify",
|
||||
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"placement": "main"
|
||||
"id": "apple",
|
||||
"path": "/apple",
|
||||
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "downloads",
|
||||
"placement": "sidebar"
|
||||
},
|
||||
{
|
||||
"id": "quickTips",
|
||||
"placement": "sidebar"
|
||||
"id": "amazon",
|
||||
"path": "/amazon",
|
||||
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
|
||||
"statusCode": 301
|
||||
}
|
||||
]
|
||||
],
|
||||
"podcastFeaturedLinks": [
|
||||
{
|
||||
"id": "sample-highlight-1",
|
||||
"title": "Introduction to Titus",
|
||||
"episodeNumber": "2",
|
||||
"summary": "A practical walkthrough of context, structure, and application for this study.",
|
||||
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
|
||||
"embedUrl": "",
|
||||
"showNotes": "",
|
||||
"discussionQuestions": [
|
||||
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
|
||||
"2. The letter’s core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
|
||||
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seo": {
|
||||
"title": "Verse by Verse with Nate",
|
||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
"ogTitle": "Verse by Verse with Nate",
|
||||
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
|
||||
"ogImage": "/images/podcast-art.jpeg",
|
||||
"canonicalUrl": "https://versebyversewithnate.us/",
|
||||
"robotsPolicy": "index,follow",
|
||||
"sitemapPaths": [
|
||||
"/",
|
||||
"/start-here",
|
||||
"/questions",
|
||||
"/privacy",
|
||||
"/terms"
|
||||
]
|
||||
},
|
||||
"legal": {
|
||||
"privacyTitle": "Privacy Policy",
|
||||
"privacyBody": [
|
||||
"We respect your privacy and collect limited data to operate and improve this site.",
|
||||
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
|
||||
"Contact form details are used only to respond to your message and ministry communication requests."
|
||||
],
|
||||
"termsTitle": "Terms",
|
||||
"termsBody": [
|
||||
"Content on this site is for informational and ministry purposes.",
|
||||
"External links are provided for convenience and are subject to third-party policies.",
|
||||
"By using this site, you agree to lawful use and respectful communication."
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-03-19T19:19:04.291Z"
|
||||
"updatedAt": "2026-04-30T18:06:00.017Z"
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
[
|
||||
{
|
||||
"id": "cb_001",
|
||||
"type": "qa",
|
||||
"title": "What Bible translation do you use?",
|
||||
"content": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"keywords": [
|
||||
"bible",
|
||||
"translation",
|
||||
"bsb",
|
||||
"berean standard bible",
|
||||
"version",
|
||||
"podcast",
|
||||
"teaching"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"type": "qa",
|
||||
"title": "How do you approach a difficult or confusing Bible passage?",
|
||||
"content": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"keywords": [
|
||||
"difficult",
|
||||
"confusing",
|
||||
"passage",
|
||||
"context",
|
||||
"commentary",
|
||||
"study",
|
||||
"interpretation",
|
||||
"hard"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"type": "qa",
|
||||
"title": "How do I stay consistent with daily Bible reading?",
|
||||
"content": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"keywords": [
|
||||
"daily",
|
||||
"reading",
|
||||
"consistent",
|
||||
"habit",
|
||||
"bible",
|
||||
"plan",
|
||||
"youversion",
|
||||
"devotional",
|
||||
"routine",
|
||||
"morning"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_005",
|
||||
"type": "topic",
|
||||
"title": "About the Verse by Verse with Nate Podcast",
|
||||
"content": "Verse by Verse with Nate is a podcast dedicated to walking through books of the Bible chapter by chapter and verse by verse. The goal is to help everyday listeners develop a deep, contextual understanding of Scripture — not just surface-level summaries, but the meaning behind the words. New episodes are released regularly. You can listen on Spotify, Apple Podcasts, and other major platforms.",
|
||||
"keywords": [
|
||||
"podcast",
|
||||
"about",
|
||||
"verse by verse",
|
||||
"nate",
|
||||
"listen",
|
||||
"show",
|
||||
"episodes",
|
||||
"what is"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_006",
|
||||
"type": "topic",
|
||||
"title": "How to listen to the podcast",
|
||||
"content": "You can listen to Verse by Verse with Nate on Spotify or Apple Podcasts. Just search 'Verse by Verse with Nate' in your favorite podcast app. New episodes drop regularly — subscribing will make sure you never miss one. If you'd like to leave a review, that helps more people find the show!",
|
||||
"keywords": [
|
||||
"listen",
|
||||
"subscribe",
|
||||
"spotify",
|
||||
"apple podcasts",
|
||||
"find",
|
||||
"where",
|
||||
"app",
|
||||
"review"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"type": "qa",
|
||||
"title": "How do I submit a question to Nate?",
|
||||
"content": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"keywords": [
|
||||
"submit",
|
||||
"question",
|
||||
"contact",
|
||||
"ask",
|
||||
"send",
|
||||
"form",
|
||||
"email"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_008",
|
||||
"type": "topic",
|
||||
"title": "Prayer and quiet time",
|
||||
"content": "Prayer is simply talking to God — it doesn't have to be formal or long. A quiet time usually involves reading a passage of Scripture, reflecting on what it means for your life today, and spending a few minutes in prayer. You must make sure that you stay in context of what Scripture says and not put your own thoughts or words into what the Bible is saying. Many people find journaling helpful. The key is consistency, not perfection. Even five minutes a day done faithfully is far better than an hour once a week.",
|
||||
"keywords": [
|
||||
"prayer",
|
||||
"quiet time",
|
||||
"devotional",
|
||||
"pray",
|
||||
"journal",
|
||||
"meditate",
|
||||
"reflect",
|
||||
"morning",
|
||||
"time with god"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "167fa083-5438-44a5-ac72-6dd379e937f5",
|
||||
"type": "episode",
|
||||
"title": "Episode 2 — Introduction to Titus (Background & Overview)",
|
||||
"content": "WHO WAS PAUL? Let’s start at the beginning. The letter opens in verse 1 with the words: Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, Now, if you’ve spent any time in the New Testament, you’ve heard of Paul. But let me give you the short version, because I think it matters here. Paul — originally named Saul — was a man who, by his own description, was a Hebrew of Hebrews. He was a Pharisee. He was educated, zealous, and deeply committed to the law of Moses. And for a season of his life, that zeal led him to do something he would carry with him for the rest of his days: he persecuted the early church. He approved of the stoning of Stephen. He dragged men and women out of their homes and had them thrown into prison. And then — on a road to Damascus — everything changed. He met the risen Jesus. And the man who was once the church’s greatest enemy became its most tireless ambassador. Paul went on to plant churches across the Roman world. He traveled through modern-day Turkey, Greece, Macedonia. He wrote letters that would become a third of our New Testament. He was beaten, shipwrecked, imprisoned — and through all of it, he kept going. Why does this matter for Titus? Because the man writing this letter is not writing from a place of comfort or theory. Paul understood, in the most personal way possible, what it meant to be completely transformed by grace. That’s going to come through on every page. WHO WAS TITUS? Now, who is Titus? Titus doesn’t get as much attention as, say, Timothy — but he should. He’s one of Paul’s closest and most trusted co-workers, and the more you look at him, the more impressive he becomes. Here’s what we know: Titus was a Gentile. He wasn’t Jewish. He came to faith through Paul’s ministry — which is why Paul, in verse 4 of this very letter, calls him: To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. That phrase is loaded. Paul is saying: you and I share the same gospel. You are my son in the faith. If you want to understand Titus better, look at a few other places in Paul’s letters. In 2 Corinthians chapter 7, Paul describes being comforted by the arrival of Titus during a particularly difficult season. In chapter 8, he sends Titus to help organize a relief offering for struggling believers in Jerusalem. And in Galatians chapter 2, Titus shows up as something of a test case — proof, in human form, that the gospel is for everyone. This is a man Paul trusted with hard things. And that’s important — because what he’s being asked to do in Crete is very hard. WHERE IS CRETE? Which brings us to the island. Crete. If you look at a map of the Mediterranean, you’ll find it in the southern Aegean Sea — a large island stretching about 160 miles from east to west. It was a well-known place in the ancient world. It had a long history, a busy coastline, and a reputation. And not necessarily a good one. Paul actually quotes a Cretan poet in this very letter — we’ll get to that in chapter 1. Crete had a reputation for dishonesty, for moral looseness, for people who were, frankly, difficult to work with. The cultural environment was a challenging one for a young church trying to take root. There were also false teachers mixing Jewish influences with the faith in unhealthy ways. Paul is going to address that head-on. So picture Titus: a Gentile pastor, on a difficult island, in a church still finding its footing, surrounded by cultural pressures and false teachers. This letter is his assignment briefing. WHY WAS THE LETTER WRITTEN? In verse 5, Paul gets right to the point: The reason I left you in Cr",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 2",
|
||||
"titus 2:11",
|
||||
"introduction",
|
||||
"background",
|
||||
"overview",
|
||||
"crete",
|
||||
"letter"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.315Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "881279c7-3ca4-436d-a584-19cc0a5a9bf3",
|
||||
"type": "episode",
|
||||
"title": "Episode 3 — Who Is Paul, and Why Does This Letter Exist? (Titus 1:1– 4)",
|
||||
"content": "SERVANT AND APOSTLE Let’s start by reading Titus Chapter 1 Verses 1-4 I am reading from The Berean Standard Bible ( BSB) . 1 Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, 2 in the hope of eternal life, which God, who cannot lie, promised before time began. 3 In His own time He has made His word evident in the proclamation entrusted to me by the command of God our Savior. 4 To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. Paul opens with two words to describe himself: servant and apostle. The order matters, and the pairing is intentional. Apostle is a word of authority — a commissioned representative sent with the full weight of the sender’s backing behind them. Paul is saying: I speak on behalf of Jesus Christ. What I write carries weight. But before he says apostle, he says servant. In the Greek, the word is doulos (doo'- los ) — bondservant – a person who chooses to be a servant one who belongs entirely to another. Paul is saying: before anything else, I am owned. Everything I do flows from submission to the God I serve. This combination — servant and apostle — is a picture of what healthy spiritual authority looks like. It is never raw power. It is authority that comes from below — from a posture of submission — exercised in service, not dominance. Paul is modeling for Titus, right here in the opening line, what it looks like to lead. You don’t choose between humility and authority. You hold both. At the same time. TRUTH THAT LEADS TO GODLINESS In verse 1, Paul describes his mission as being for “the faith of God’s elect and their knowledge of the truth that leads to godliness.” That last phrase is the key: truth that leads to godliness. Paul is not interested in truth for its own sake — collecting correct theological propositions the way some people collect rare books, impressive on the shelf but never actually read. For Paul, truth has a direction. Truth moves. Truth produces something. What does it produce? Godliness. A life shaped by what it believes. This is the big theme of the entire letter — sound doctrine and transformed living are not separate categories. They are inseparable. The truth you know should be shaping the way you live. If it isn’t — if your theology is just mental furniture — something has gone wrong. BEFORE TIME BEGAN Now we get to one of the most stunning phrases in the entire letter. Verse 2: …in the hope of eternal life, which God, who cannot lie, promised before time began. Before time began. Let that land for a second. Paul is saying that the plan of salvation was not an afterthought. It was not Plan B. It was not God improvising a rescue after the fall. It was a promise made before the universe existed. Before the first atom, the first star, the first breath — God had already determined to save a people for Himself. And notice what Paul says about the God who made that promise: He cannot lie. Not He does not lie. He cannot lie. It is outside His nature. It is impossible. That means the promise of eternal life is not hanging on your faithfulness, or your feelings, or whether you had a good week. It is hanging on the character of a God who is constitutionally incapable of breaking His word. For Titus, laboring on a difficult island with a struggling church and real opposition — this is the ground he stands on. Not his own strength. The unchangeable promise of an undeceivable God. ENTRUSTED BY COMMAND Verse 3 adds one more layer to Paul’s self-understanding: …In His own time He has made His word evident in the proclamation entrusted to me by the",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 3",
|
||||
"titus 1:1– 4",
|
||||
"titus 1:1",
|
||||
"paul",
|
||||
"does",
|
||||
"this",
|
||||
"letter",
|
||||
"exist"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.347Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "e0ad268f-2826-41a5-b078-90f90aeda21b",
|
||||
"type": "episode",
|
||||
"title": "Episode 4 — What Does a Faithful Leader Look Like? (Titus 1:5–9)",
|
||||
"content": "WHY ELDERS FIRST? Remember where we are in the letter. Paul has just finished his opening and the very first practical instruction he gives Titus is this: The reason I left you in Crete was that you would set in order what was unfinished and appoint elders in every town, as I directed you. Appoint elders. That’s the first thing. Because a church without mature, qualified leadership is vulnerable. Think of it this way: Paul had planted the gospel in Crete, and people had come to faith. But a collection of new believers without shepherds is like a flock without a shepherd — it can survive for a season, but it will eventually scatter or get picked off. Leadership is not bureaucracy. In Paul’s vision, it’s protection. It’s structure that allows the gospel to take root across generations. Titus’s first assignment is to build the infrastructure that makes everything else possible. VERSE 6: THE HOUSEHOLD QUALIFICATION Before Paul moves to the broader character portrait in verses 7 and 8, he pauses in verse 6 to say something about the elder’s household. It’s easy to read past it. Don’t. An elder must be blameless, the husband of but one wife, having children who are believers and who are not open to accusation of indiscretion or insubordination. The elder’s home is exhibit A. His marriage, his children, the atmosphere of his household — these are not private matters separate from his fitness to lead. They are the primary evidence of it. Think about what Paul is saying. Before Titus looks at a man’s preaching or his public presence, he should look at his home. Is his marriage marked by faithfulness? Are his children — the people who know him best, who cannot be impressed or deceived — living in a way that reflects what he believes? Paul’s logic is plain: if a man cannot lead his own household well, there is no reason to believe he will lead God’s household well. The church is not a stage where a person performs. It is a family — and family life requires the same qualities at home and at the pulpit. Integrity cannot be compart - mentalized. This isn’t a demand for perfection — Paul is not saying an elder must have flawless children. He says they must not be open to accusation of wild living or insubordination. The standard is a household genuinely ordered by the same faith the elder proclaims. ELDER AND OVERSEER: TWO WORDS, ONE ROLE In verses 5 through 7, Paul uses two words interchangeably: elder and overseer. Elder — presbyteros — carries the idea of maturity and respect. It speaks to someone the community looks to for wisdom. Overseer — episkopos — is more functional. It means one who watches over, who takes responsibility for the welfare of others. It emphasizes the work of the role. Together they give us the full picture. The person leading a local church is someone who is mature in faith AND active in oversight. Not just respected but responsible. Not just experienced but engaged. THE FIVE ‘NOTS’ AND THE POSITIVES In verses 7 and 8, Paul gives Titus a remarkably specific list. Let’s read them: As God’s steward, an overseer must be above reproach—not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy for money. Five things an elder must not be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy for money. Then in verse 8, six things he must be: Hospitable — someone whose home and life are genuinely open to others. A lover of good — someone drawn toward what is right and true and beautiful. Self-controlled — someone who governs their own desires and impulses. Upright — someone whose dealings with others are fair and straight. Holy — someon",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 4",
|
||||
"titus 1:5–9",
|
||||
"titus 1:5",
|
||||
"what",
|
||||
"does",
|
||||
"faithful",
|
||||
"leader",
|
||||
"look",
|
||||
"like"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.369Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "56626549-7e1e-4564-8c79-2d21b49eb911",
|
||||
"type": "episode",
|
||||
"title": "Episode 5 — The Danger of Empty Words (Titus 1:10–13a)",
|
||||
"content": "WHO WERE THE CIRCUMCISION GROUP? Paul identifies the primary troublemakers in verse 10: For many are rebellious and full of empty talk and deception, especially those of the circumcision, “Those of the circumcision” refers to some of the Jewish Christians — or people who were influenced by Jewish tradition — they were insisting that Gentile believers needed to follow the Mosaic law in addition to having faith in Christ. The specific issue was circumcision — but it stood for a whole system. The argument was essentially: Jesus is necessary, but not sufficient. You also need the law, Jewish customs, certain food regulations, certain rituals. For Paul, this was not a secondary debate. It was a direct attack on the gospel itself. The moment you add requirements to grace, you have changed what grace is. Paul’s entire ministry was grounded in one truth: salvation comes through Christ alone. Jesus’ life, death, and resurrection are fully sufficient. Nothing needs to be added. Faith in Jesus is all that is required to be reconciled to God. THREE GREEK WORDS In verse 10, Paul uses three Greek words to describe these teachers, and each one is precise. ἀνυπότακτος ( any - potak - tos ) — rebellious. Literally not submitted, not under authority. These people have placed themselves outside accountability. ματαιολόγος ( mata - io - logos ) — full of empty talk. Combines mata - ios (empty, vain) with logos (word). Religious noise without substance. φρεναπάτης ( phren - a - patēs ) — deception. Literally one who deceives the mind. Not just wrong content — distorting the way a person thinks from the inside. Rebellious. Empty talkers. Mind-deceivers. These aren’t accidental mistakes . These are people actively leading others in the wrong direction. Paul wants Titus to see them clearly. UNDERMINING ENTIRE HOUSEHOLDS Verse 11 tells us the scale of the damage: …who must be silenced. For the sake of dishonorable gain, they undermine entire households and teach things they should not. Whole families ! In the first-century world, the household was the basic unit of society. Churches met in homes. Faith was practiced in families. When a false teacher reshaped what a household believed, the damage spread through every relationship in that home. And notice the motive Paul names: dishonorable gain. These teachers weren’t doing this out of sincere conviction. They were doing it for money. For influence. For the advantages that came from building a following. That’s the pastoral warning. Not everyone who teaches with confidence is teaching the truth. In the Garden of Eden, Satan spoke convincingly while distorting what God had said. And not everyone who sounds sincere is free from self-interest. WHY PAUL QUOTES EPIMENIDES In verse 12, Paul quotes a Cretan poet — Epimenides — who said: “Cretans are always liars, evil beasts, lazy gluttons.” And then in verse 13 Paul adds: This testimony is true. Therefore rebuke them sternly, so that they will be sound in the faith This has confused readers for centuries. Is Paul endorsing ethnic stereotyping? No. He is engaged in ministry that takes context into account. He’s saying to Titus: the culture you’re working in has certain well-known tendencies. O ne of their own poets — said so. This isn’t about ethnicity; it’s about a specific cultural pattern of dishonesty that the false teachers were both shaped by and exploiting. Paul isn’t writing off Crete. He’s telling Titus to go in with eyes open. Know where you are. Know what you’re working against. REBUKE AS AN ACT OF LOVE Verse 13 also contains an instruction that modern ears often s",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 5",
|
||||
"titus 1:10",
|
||||
"danger",
|
||||
"empty",
|
||||
"words",
|
||||
"false",
|
||||
"teacher",
|
||||
"titus 1:13"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.396Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "dd6b3af4-44a5-43f1-9d69-ab12c63dd46d",
|
||||
"type": "episode",
|
||||
"title": "Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)",
|
||||
"content": "A DEFILED CONSCIENCE AND A DISTORTED WORLD Verse 15 contains one of the most psychologically precise statements in all of Paul’s letters: To the pure, all things are pure; but to the defiled and unbelieving, nothing is pure. Indeed, both their minds and their consciences are defiled. A person with a clean conscience — someone whose heart has been washed by the gospel — can engage with the world from a place of trust and clarity. But a person whose conscience is defiled — corrupted by sin, self-deception, or bad doctrine — can’t see anything clearly. Everything gets filtered through that corruption. Even what is pure looks suspect to them. This is why false teaching is so damaging at the root level. It doesn’t just change what people believe — it changes how they see. Think about what that looks like practically. A person shaped by bad doctrine doesn’t just hold wrong opinions — they become suspicious of grace, hostile toward correction, and unable to receive what is genuinely good. They see generosity and call it ingenuousness . They see freedom and call it license. The very gifts of the gospel become things they can’t trust, because the lens they’re looking through has been cracked. That’s the devastation Paul is describing — not just theological error, but a person who has lost the ability to see clearly. JEWISH MYTHS AND COMMANDS OF MEN Verse 14 tells us specifically what these false teachers were promoting — Jewish myths and the commands of people who have rejected the truth. Jewish myths likely refers to speculative expansions of Old Testament stories. Commands of men refers to human-made religious rules elevated to the level of divine requirement. And here’s why Paul sees this as a gospel issue: when you add human requirements to the gospel, you shift the basis of acceptance before God from what Christ has done to what you do. The result is a Christianity more anxious, more performance-driven, more focused on the maintenance of rules than on the freedom of grace. We should be honest that this isn’t only a first-century problem. The “commands of men” show up in modern dress all the time — in churches that quietly teach that real Christians hold certain political views, in communities where your worth is measured by how often you volunteer, how much you give, or whether you use the right vocabulary. None of those things are bad in themselves. But the moment they become the measure of your standing before God, the gospel has been displaced. Paul’s warning is just as sharp now as it was in Crete. DETESTABLE, DISOBEDIENT, UNFIT Verse 16 ends with a devastating summary: They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed. Three words. Detestable. Disobedient. Unfit for any good deed. The first word Paul uses - Detestable - describes something morally repulsive — something unacceptable before God . It’s the same kind of word the Old Testament used for things that were considered abominations. In other words, these teachers may look religious on the outside, but in God’s sight their teaching and conduct are deeply offensive. The second word - Disobedient - describes someone who is not persuadable — someone resistant to truth . Not merely confused, but unwilling to submit when confronted with what is right. They have heard the truth, but they refuse to bend their lives to it. The third word – is a phrase in English but is one word in Greek - Unfit for any good deed - This comes from the world of metalworking . It refers to metal that has been tested , examined and discovered to be counterfeit — rejected because it lacks real substance. That’s the picture Paul is painting: religious appearance on the surface, but wh",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 6",
|
||||
"titus 1:13",
|
||||
"deny",
|
||||
"claim",
|
||||
"believe",
|
||||
"titus 1:16"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.417Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "1559c4af-3913-4a0d-9ac3-a59172adabc5",
|
||||
"type": "episode",
|
||||
"title": "Episode 7 — Sound Doctrine for Every Stage of Life (Titus 2:1–10)",
|
||||
"content": "THE PIVOT FROM WARNING TO VISION Chapter 1 was about threats — the things that were wrong, the people causing damage, the patterns that needed to be stopped. It was a necessary chapter. You can’t build well if you haven’t cleared the ground. Chapter 2 is about vision. Not what are we against, but what are we for. Not just what does sound doctrine forbid, but what does it produce? And what it produces is a community that looks different. Every generation investing in the next. Older believers modeling what long faithfulness looks like. Younger believers learning to bring their appetites and ambitions under the direction of the gospel. Workers whose ordinary faithfulness makes the gospel attractive. This is not a vision of individual spiritual achievement. It is a vision of intergenerational community shaped by grace. OLDER MEN Verse 2 describes what Paul wants Titus to cultivate in older men: Older men are to be temperate, dignified, self-controlled, and sound in faith, love, and perseverance. Four qualities of character, and three qualities of soul. Temperate. Dignified. Self-controlled. These describe a man who has learned — through years of walking with God and being humbled by life — to govern himself. And then: sound in faith, love, and perseverance. Faith that hasn’t grown cynical. Love that hasn’t grown cold. Perseverance that hasn’t given up. The older man who embodies these qualities is not just personally virtuous — he is a witness. He shows the younger generation that it is possible to follow Jesus for decades and still be full of faith, still warm in love, still standing. That’s a gift no sermon can replace. OLDER WOMEN AND YOUNGER WOMEN Verses 3 through 5 describe older women and their specific role in the life of younger women: Older women, likewise, are to be reverent in their behavior, not slanderers or addicted to much wine, but teachers of good. In this way they can train the young women to love their husbands and children, to be self-controlled, pure, managers of their households, kind, and submissive to their own husbands, so that the word of God will not be discredited. The older women Paul describes are not passive. They are reverent — a word used elsewhere for temple service, suggesting a life of sacred intentionality. They are not slanderers. They are not addicted to much wine. They are self-governing. And their primary ministry is to teach younger women. The Greek word for train here — sophronizō — is about forming wisdom and good judgment in someone else. This is not advice-giving. This is mentorship. Investment. One life poured into another. And the reason Paul gives for all of it is stunning: so that the word of God will not be discredited. The way the women in this congregation live their ordinary lives either adorns the gospel or brings it into disrepute. Their faithfulness is a form of witness. YOUNGER MEN AND TITUS HIMSELF The section on younger men is the shortest in the passage. Verse 6: “In the same way, urge the younger men to be self-controlled.” That’s it. One charge. And then Paul immediately turns to Titus in verse 7: In everything, show yourself to be an example by doing good works. In your teaching show integrity, dignity, The connection is important. The best thing Titus can do for the younger men is not give them a list. It’s show them a life. Paul uses the word typos — a pattern, a mold. Titus himself is to be the shape that younger men are formed around. Self-control is not a naturally occurring quality in young men. It has to be learned. And it is learned most effectively not from a lecture but from a model. ADORNING THE GOSPEL [This is the capstone i",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 7",
|
||||
"titus 2:1–10",
|
||||
"titus 2:1",
|
||||
"sound",
|
||||
"doctrine",
|
||||
"every",
|
||||
"stage",
|
||||
"life"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.441Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "6be7a644-9276-4797-a7bb-1422b170846c",
|
||||
"type": "episode",
|
||||
"title": "Episode 8 — Grace Is Not Just What Saves You — It’s What Trains You (Titus 2:11–12)",
|
||||
"content": "GRACE AS TEACHER: THE GRAMMAR MATTERS Let’s read verses 11 and 12 carefully: For the grace of God has appeared, bringing salvation to everyone. It instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives in the present age, Now — look at the grammar of verse 12. The subject of the sentence is ‘it.’ And ‘it’ refers back to grace. Grace instructs us. Grace is the subject. Grace is doing the teaching. The Greek word Paul uses is paideuō — the word for the education of a child. Not classroom instruction. Formation. The kind of learning that shapes not just what you know but who you are. Grace is the tutor. And the curriculum it’s teaching has two parts: what to say no to, and what to say yes to. WHAT GRACE TEACHES US TO SAY NO TO Grace teaches us to renounce two things: ungodliness and worldly passions. Ungodliness — asebeia — is the opposite of reverence. A life where God is not actually at the center. Where decisions are made without reference to Him. Worldly passions — desires shaped by the age we live in rather than the kingdom we’ve been brought into. And here’s what I want you to notice: Paul doesn’t say that moral effort teaches us to renounce these things. He says grace teaches us. The pathway to saying no to ungodliness is going deeper into grace — understanding more fully what you have been given. Grace, properly understood, reorients your desires. It doesn’t just change the rules. It changes what you want. WHAT GRACE TEACHES US TO SAY YES TO On the other side, grace teaches three orientations: sensible, upright, and godly. Sensible — sōphrōn — a sound, well-ordered mind. Not repressed. Not uptight. But governed. A person whose interior life is ordered enough that they can engage with the world without being swept away by it. Upright — dikaios — right in relationship with others. Fair. Honest. The gospel changes how we treat people because it has changed how we understand our own standing before God. Godly — eusebōs — living with genuine reverence. A life where God is actually at the center, not just in stated belief, but in the practical orientation of each day. Inward (sensible), outward (upright), and upward (godly). Grace reshapes the whole person. THE KEY WORD: CHARIS The Greek word for grace is charis — χάρις. It appears in the opening greeting (1:4) and in the closing benediction (3:15), bookending the entire letter. Charis means unmerited favor. A gift freely given with no precondition of worthiness. But Paul uses it here as a teacher — it initiates AND sustains. The grace that started the work in you is the same grace that is continuing it. This is why trying harder without grace fails. Moral effort without a gospel foundation is white-knuckle religion. And this is also why grace without obedience is incomplete. Grace is so powerful that it actually produces change. If it’s not producing anything in your life, Paul would want to ask whether you’ve actually encountered it. DISCUSSION QUESTIONS Three questions to carry this week: 1. In what ways is grace a teacher? How does understanding what God has freely given change your motivation for how you live? 2. What’s the difference between obeying out of fear or duty versus obeying as a response to grace? What does that difference feel like from the inside? 3. Where do you find it hardest to say no to ungodliness — and what role does grace actually play there, in your honest experience? ✦ CLOSING Grace is not a hall pass for how you live. It is a school you attend every day. The same grace that declared you forgiven is act",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 8",
|
||||
"titus 2:11–12",
|
||||
"titus 2:11",
|
||||
"grace",
|
||||
"just",
|
||||
"what",
|
||||
"saves",
|
||||
"trains"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.461Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "4e6ec41f-b083-45b9-9581-0dc91f092f66",
|
||||
"type": "episode",
|
||||
"title": "Episode 9 — Living Between Two Appearings (Titus 2:13–15)",
|
||||
"content": "THE THREE TIMEFRAMES Verse 11: “For the grace of God has appeared.” Past tense. The first appearing. The foundation. Verse 12: “…to live sensible, upright, and godly lives in the present age.” Present tense. The current moment. Verse 13: …as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ. Future tense. We are waiting actively, with anticipation, with our behavior shaped by what we know is coming. Past event. Present calling. Future hope. You can’t live the present well without both the foundation behind you and the horizon ahead of you. THE BLESSED HOPE Verse 13 uses a phrase that has become so familiar it has almost lost its force: the blessed hope. The Greek is makaria elpis. Makaria is the same word used in the Beatitudes — blessed, in a divinely favored condition. And elpis — hope — in the New Testament is not the way we use the word in everyday conversation. We say ‘I hope it doesn’t rain’ — meaning we’re uncertain, we’re wishing. Biblical hope is a confident expectation of something certain. Not a wish. An anchor. The blessed hope is: Jesus is coming back. Not maybe. Not eventually, probably. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today. THE GOSPEL IN ONE SENTENCE Verse 14 does something remarkable. In a single sentence, Paul gives us the full scope of what Christ’s death accomplished: He gave Himself for us to redeem us from all lawlessness and to purify for Himself a people for His own possession, zealous for good deeds. Two verbs. Redeem and purify. Redeem — lytroō — a word from the slave market and from Exodus. To pay a price to set someone free. What were we freed from? All lawlessness. Purify — katharizō — from the temple. To cleanse, to make acceptable. We weren’t just released from prison — we were made clean. Belonging. And look at the end: ‘a people for His own possession, zealous for good deeds.’ The word zelotes — eager, passionate, on fire. Not reluctant. Not dragging their feet. People who are genuinely excited about doing good because they understand what they’ve been rescued for. SPEAK WITH AUTHORITY Paul closes the chapter with a charge to Titus: Speak these things as you encourage and rebuke with all authority. Let no one despise you. Speak these things. All of it — the grace, the training, the blessed hope, the gospel in one sentence. Don’t soften it. Don’t apologize for it. With all authority. Not Titus’s own authority — the authority of the message itself. The gospel doesn’t need Titus’s permission to be true. His job is to represent it faithfully and let it carry its own weight. DISCUSSION QUESTIONS Three questions for this week: 1. How does actively anticipating the return of Christ affect the way you make decisions and treat people today? Does the blessed hope feel like a real anchor in your life? 2. What does it mean to you personally that Christ ‘gave Himself’ — that it was intentional, not accidental? 3. Do you experience your Christian life as something you belong to — a people claimed by God — or mostly as something you have to maintain on your own? ✦ CLOSING We are living between two great appearings of grace. The first established the foundation. The second will complete what was begun. The in-between space is not a waiting room. It’s a mission field. We don’t pace and wait. We live — sensible, upright, godly, zealous for good deeds — shaped by what we know is coming. Next ep",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 9",
|
||||
"titus 2:13–15",
|
||||
"titus 2:13",
|
||||
"living",
|
||||
"between",
|
||||
"appearings"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.484Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "41233c63-d2b4-4fd3-9054-1afb11c37287",
|
||||
"type": "episode",
|
||||
"title": "Episode 10 — How the Gospel Forms a Community (Titus 3:1–2)",
|
||||
"content": "WHY PAUL OPENS CHAPTER 3 WITH CIVIC SUBMISSION Paul is not saying: agree with every government decision. He is saying: your engagement with public life should be marked by something recognizable as different. Your submission, your readiness to do good, your peaceable presence — these are not just civic virtues. They are missionary strategies. The way the community in Crete navigates public life is either going to open doors for the gospel or close them. THREE ACTIVE ORIENTATIONS Verse 1: Remind the believers to submit to rulers and authorities, to be obedient and ready for every good work, Submit to rulers and authorities — a posture of respect toward legitimate governing structures. The believer’s default is cooperation rather than defiance. Be obedient — follow through. Actually do what is asked. People whose lives don’t create unnecessary friction with the society around them. Be ready for every good work — active, not passive. Readiness implies preparation and eagerness. The believer’s posture is not just ‘don’t cause problems.’ It’s ‘be looking for opportunities to do good.’ THE POSTURE OF BELIEVERS IN A CONFLICT-SATURATED CULTURE Verse 2 adds four more qualities: …to malign no one, and to be peaceable and gentle, showing full consideration to everyone. Malign no one. In a culture built on callouts, political attack cycles, and the steady background noise of contempt — this is genuinely countercultural. Paul doesn’t say: malign no one except your political opponents. He says no one. Peaceable — amachous — literally non-combative. Alpha (not) + machē (battle). People who are not itching for a fight, not framing every interaction as a conflict to be won. Gentle — epieikēs — gracious, not insisting on the full weight of one’s rights. Showing full consideration to everyone — not just the likable, the agreeable, the theologically correct. For all people. This is not weakness. This is the confidence of people who know how the story ends and don’t need to win every argument to prove it. DISCUSSION QUESTIONS Three questions for this week: 1. How should the gospel shape the way Christians engage with politics, authority, and public life? Where do you see the church getting this right — and where do you see it getting it wrong? 2. What does it look like to be gentle and considerate to everyone in the age of social media? What would need to change in your online presence to reflect the posture Paul describes here? 3. Where do you find it hardest to reflect the peaceable posture Paul describes? What situation or kind of person most tests your gentleness? ✦ CLOSING The gospel sends us into the world not as culture warriors, but as witnesses. The posture Paul calls for — gentle, peaceable, considerate, ready for every good work — is the confidence of people who know who they belong to and what the future holds. The confidence of people who know the King doesn’t look like aggression. It looks like peace. Next episode, before Paul gives us the gospel again in its fullest form, he gives us the diagnosis. Verse 3 is one of the most honest passages in Titus — and Paul includes himself in it. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 10 —",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 10",
|
||||
"titus 3:1–2",
|
||||
"titus 3:1",
|
||||
"gospel",
|
||||
"forms",
|
||||
"community"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.506Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "6d0def21-46cb-497e-ac20-8e09c5368a08",
|
||||
"type": "episode",
|
||||
"title": "Episode 11 — Remember What You Were (Titus 3:3)",
|
||||
"content": "THE ‘WE TOO WERE’ Let’s read the verse: For at one time we too were foolish, disobedient, misled, and enslaved to all sorts of desires and pleasures—living in malice and envy, being hated and hating one another. We ourselves. This is not rhetoric. This is autobiography. Paul is giving instructions on how to correct others — how to rebuke false teachers, how to call the congregation to a higher standard. And right here he stops and says: before you do any of that, remember what you were. This is the ground of Christian humility. Every time a believer looks at someone who is not yet a Christian — someone whose life is being destroyed by these very patterns — the right response is not contempt. It is recognition. I was there. I was enslaved to those same kinds of things. I didn’t save myself. I was saved. SEVEN DESCRIPTORS Paul gives us seven words for what we were before grace. Each one is precise. Foolish — anoētos — without understanding. Not unintelligent. Without the wisdom that comes from knowing God. Disobedient — apeitheis — not persuadable, resistant to truth. Misled — planōmenoi — wandering, deceived, off course. Confidently heading in the wrong direction. Enslaved to all sorts of desires and pleasures — douleuontes — serving as slaves. Not choosing. Owned by desires. Living in malice and envy — ordinary corrosive stuff — wanting what others have, being glad when they fail. Being hated and hating one another — the natural end state of a life shaped by malice and envy. Seven words for what we were. And this is not a description of the worst people in the world. This is a description of all of us, before grace. HONEST MEMORY AS A PASTORAL GIFT Why does Paul make Titus — and us — look at this? Because the people Titus is ministering to include people who are still in this condition. And it would be easy — especially for a young pastor who has been well-formed by the gospel — to look at them with spiritual impatience. To treat their condition as a problem to be solved rather than a darkness to be illuminated with compassion. Paul is cutting that off at the root. You were there. You did not extricate yourself. You were found. There is a kind of humility that only grows from honest memory. The person who genuinely remembers what they were before grace is not capable of looking down on someone who doesn’t yet have it. They are too aware of the distance grace had to travel to reach them. DISCUSSION QUESTIONS Three questions to sit with this week: 1. Why is Paul’s autobiographical ‘we’ so important here? How does remembering your own ‘before’ picture shape your attitude toward people who don’t yet know the gospel? 2. Which of the seven descriptors — foolish, disobedient, misled, enslaved, malicious, envious, hated and hating — do you most need to keep in view to stay humble? 3. How does honest self-awareness about what we were become a gift for the way we love others? Where have you seen that in practice? ✦ CLOSING Humility toward others grows from memory. We don’t look down on where people are — because we remember where we were. And we remember that we didn’t save ourselves. Grace came to us. Grace found us in the dark and brought us out. That memory is not a wound. It’s a gift. Next episode, we get the gospel in its fullest form — Titus 3:4 through 7 is one of the most concentrated and beautiful gospel summaries anywhere in the New Testament. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 11 —",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 11",
|
||||
"titus 3:3",
|
||||
"titus 3:4",
|
||||
"remember",
|
||||
"what",
|
||||
"were"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.532Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "b68c5659-cc7e-42d2-ab24-a623b7404058",
|
||||
"type": "episode",
|
||||
"title": "Episode 12 — The Gospel in One Paragraph (Titus 3:4–7)",
|
||||
"content": "SALVATION IS TRINITARIAN All three persons of the Trinity are present and active in our salvation. This is not accidental — Paul is showing us that the gospel is not a one-person operation. God the Father — verse 4 speaks of ‘the kindness of God our Savior and His love for mankind.’ The initiating motivation for salvation is the character of the Father. He is kind. He is full of love toward humanity. Our rescue begins in the heart of God. The Holy Spirit — verse 5 speaks of ‘the washing of new birth and renewal by the Holy Spirit.’ The Spirit is the agent of the new birth — the one who applies the work of Christ to the individual soul. Jesus Christ — verse 6 names Him explicitly as the one through whom the Spirit is poured out, and verse 7 calls Him ‘our Savior.’ Father, Son, Spirit — each person fully present, each playing a distinct role, all working together in the single act of saving a person. This is not just theology. It is the shape of your own story. NOT BY OUR DEEDS: MERCY, NOT MERIT Verse 5 contains one of the most explicit denials of works-based salvation in the New Testament: But when the kindness of God our Savior and His love for mankind appeared, He saved us, not by the righteous deeds we had done, but according to His mercy, through the washing of new birth and renewal by the Holy Spirit. Not by the righteous deeds we had done. Not partially. Not mostly. The basis of salvation is not our righteousness. It is His mercy. The theological term for this is monergism — salvation as a single-agent work. God does the saving. We receive it. There was nothing in the raw material that deserved rescue. The rescue happened anyway, because of mercy. And why does this matter so much? Because if salvation is even one percent based on our merit, it is unstable. But if salvation is entirely based on God’s mercy — on the character of a God who cannot lie and who loved humanity enough to appear as a human and die — then it rests on something that cannot change. NEW BIRTH AND RENEWAL Verse 5 gives us two words for what the Spirit does in us: the washing of new birth and renewal. Washing of new birth — palingenesia — literally ‘again-genesis.’ A new beginning. A second creation. This is the moment the Spirit brings a spiritually dead person to life. A definitive event. A before-and-after. Renewal by the Holy Spirit — anakainōsis — ongoing work. Continuous present tense in the Greek. The Spirit who brought you to life at regeneration is also continually renewing you. Both are needed. New birth without renewal produces a static faith. Renewal without new birth produces endless effort without a genuine starting point. Both are the Spirit’s work. Your part is to cooperate with what He is already doing. JUSTIFIED AND HEIRS Verse 7 adds two more categories: This is the Spirit He poured out on us abundantly through Jesus Christ our Savior, so that, having been justified by His grace, we would become heirs with the hope of eternal life. Justified — dikaiōthentes — a courtroom term. To be declared righteous. Not to be made righteous in our behavior — but declared righteous in our standing. The verdict rendered over you in the courtroom of God is: not guilty. Accepted. Righteous in Christ. Heirs — klēronómoi — relational. An heir belongs to a family. Has a place at the table. Has a guaranteed share in the inheritance. You are not just legally acquitted. You are a member of the family. The eternal life promised before time began is not a prize you might win. It is an inheritance secured for you. DISCUSSION QUESTIONS Three questions for this week: 1. Why is it so hard for people — including long-time Christians — to fully accept that salvation is en",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 12",
|
||||
"titus 3:4–7",
|
||||
"titus 3:4",
|
||||
"gospel",
|
||||
"paragraph"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.554Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "c8db1a86-346f-4bad-ba4f-30c29d95feb3",
|
||||
"type": "episode",
|
||||
"title": "Episode 13 — What Happens After the Gospel Takes Root (Titus 3:8–11)",
|
||||
"content": "THE FAITHFUL SAYING Verse 8: This saying is trustworthy. And I want you to emphasize these things, so that those who have believed God will take care to devote themselves to good deeds. These things are excellent and profitable for the people. Pistos ho logos — this saying is trustworthy. Paul uses this formula in his letters to Timothy and Titus to mark something as having the highest doctrinal weight. What he just said in verses 4 through 7 is the trustworthy, reliable, foundational word. Bank on it. Build on it. And then immediately: the expected outcome. Those who have believed God will take care to devote themselves to good deeds. The test of the gospel having taken root is not doctrinal sophistication. It is practical devotion to good works. PROFITABLE VS. POINTLESS AND WORTHLESS Verse 9: But avoid foolish controversies, genealogies, arguments, and quarrels about the law, because these things are pointless and worthless. Profitable — ōphélima. Pointless and worthless — anōphelēs and mataioi. The contrast is total. Good works produce something. They are excellent — kalá — beautiful, genuinely valuable. They benefit people. They demonstrate the gospel in tangible ways. Foolish controversies, on the other hand, produce nothing. They generate heat but no light. They create factions but no fruit. The test Paul applies is not: is this interesting? Is this theologically sophisticated? The test is: does engaging with this produce anything? Does it make people more devoted to good works? SOUND DOCTRINE PRODUCES GOOD WORKS Let me name the thread Paul has been pulling through this entire letter, because we’re near the end and it is clearer here than anywhere else. From chapter 1, verse 1: truth that leads to godliness. Elders who hold firmly to sound doctrine. False teachers whose lives deny what they claim to believe. Chapter 2: grace that instructs us to live sensible, upright, godly lives. Ordinary faithfulness that adorns the gospel. Chapter 3: people who have believed God devoting themselves to good deeds. The thread is this: sound doctrine and transformed living are not separate categories. Doctrine is the seed. Good works are the fruit. You cannot have genuine doctrine without eventual fruit, and you cannot sustain genuine good works without doctrinal roots. THE DIVISIVE PERSON: TWO WARNINGS, THEN REJECTION Verses 10 and 11: Reject a divisive man after a first and second admonition, knowing that such a man is corrupt and sinful; he is self-condemned. Two warnings. Not immediate removal. Not endless patience. Two warnings. One warning is not enough — it doesn’t give the person a genuine chance to respond. The second warning is confirmation that the first was heard and not responded to. After two warnings with no change — have nothing more to do with him. And Paul’s assessment: self-condemned. The verdict is not coming from the outside. The person who persists in division after two clear warnings has essentially condemned themselves by their own choices. The community is simply recognizing what is already true. This is not cruelty. It is honesty. And it protects the people who are being divided. DISCUSSION QUESTIONS Three questions for this week: 1. Why is good theology supposed to produce good deeds — and what happens in a person or a church when doctrine and life become disconnected from each other? 2. What kinds of theological discussions do you think fall into Paul’s ‘pointless and worthless’ category today? How do you tell the difference between a discussion worth having and one that just generates heat? 3. Why does Paul prescribe two warnings rather than immediate removal or endless patience for divisive people? What does that process protect? ✦ CLOSING [Grounde",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 13",
|
||||
"titus 3:8–11",
|
||||
"titus 3:8",
|
||||
"what",
|
||||
"happens",
|
||||
"after",
|
||||
"gospel",
|
||||
"takes",
|
||||
"root"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.582Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "14ced988-1382-4c8d-a29e-b0f531580af1",
|
||||
"type": "episode",
|
||||
"title": "Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)",
|
||||
"content": "REAL PEOPLE IN A REAL COMMUNITY Verses 12 through 14 name four people: Artemas, Tychicus, Zenas the lawyer, and Apollos. Tychicus appears in several of Paul’s letters as a trusted messenger. Apollos is mentioned in Acts and 1 Corinthians as a gifted teacher. Zenas is only mentioned here. Artemas we know nothing about beyond this verse. Why does Paul name them? Because the gospel travels through people. Not as an abstract idea floating through the air, but carried by specific men and women who have been changed by it and who are willing to take it somewhere difficult at personal cost. Titus is not alone on Crete. He is part of a network. The church in the first century was a community of people in motion — bound together by a shared gospel and a shared Lord. That is still how it works. DEVOTED TO GOOD WORKS TO MEET PRESSING NEEDS Verse 14: And our people must also learn to devote themselves to good works in order to meet the pressing needs of others, so that they will not be unfruitful. Two things worth noticing. First: let our people learn. Devotion to good works has to be cultivated, practiced, formed over time. It doesn’t just happen because you believe the right things. Second: to meet the pressing needs of others. The word is anagkaias — necessary, pressing, urgent. Paul is not describing optional generosity. He is describing a community that has made itself available for the real needs that arise around it. And the consequence of not doing this: be unfruitful. The gospel that has been received and believed and proclaimed — if it doesn’t produce this kind of practical, neighbor-serving fruit — something is missing. GRACE AS BOOKEND Let’s look at the two times grace appears at the frame of this letter. Titus 1:4 — the opening: “Grace and peace from God the Father and Christ Jesus our Savior.” In the opening, grace is the origin. It comes from God and from Christ. It is something received, given from outside, descending as pure gift. Titus 3:15 — the closing: “Grace be with all of you.” In the closing, grace is the blessing. It is what Paul wants them to be with — not wealth, not safety, not resolved problems. Grace. Everything that happens between those two bookends flows from the first grace and points toward the second. The elders who lead well are stewards of grace. The community formed generation by generation is shaped by grace. The gospel paragraph is the concentrated statement of grace. The good works that follow are the fruit of grace. Grace is not a footnote in Titus. It is the frame. It is the air the letter breathes. THE LETTER’S ONE COHERENT VISION Let me take you through the structure of the letter one final time. Chapter 1 — Leadership. Establish qualified elders. The elder who holds firmly to sound doctrine is the church’s first line of protection. Character is not the entry point for leadership — it is the definition of it. Chapter 2 — Community Formation. Every generation — older men, older women, younger women, younger men, enslaved people — is addressed. Grace instructs. Grace trains. Grace produces a particular kind of life in every stage and every situation. Chapter 3 — Civic and Social Life. Engage with submission, with gentleness, with readiness to do good. Remember what you were. The same grace that found you is the grace you are carrying to others. Three chapters. One vision. Sound doctrine and godly living are inseparable. What you believe should shape how you live. And the whole thing is held together by the grace that started it and the grace that will complete it. DISCUSSION QUESTIONS Three final questions — take your tim",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 14",
|
||||
"titus 3:12",
|
||||
"titus 1:4",
|
||||
"titus 3:15",
|
||||
"grace",
|
||||
"starts",
|
||||
"ends",
|
||||
"review"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.605Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"type": "qa",
|
||||
"title": "How can I share my faith with skeptical family?",
|
||||
"content": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"keywords": [
|
||||
"faith",
|
||||
"family",
|
||||
"skeptical",
|
||||
"share",
|
||||
"relationship",
|
||||
"evangelism",
|
||||
"prayer",
|
||||
"witness",
|
||||
"unbeliever",
|
||||
"argument",
|
||||
"patience",
|
||||
"love"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"type": "qa",
|
||||
"title": "What is the blessed hope?",
|
||||
"content": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"keywords": [
|
||||
"blessed hope",
|
||||
"titus 2:13",
|
||||
"second coming",
|
||||
"return of Christ",
|
||||
"hope",
|
||||
"appearing",
|
||||
"makaria",
|
||||
"elpis",
|
||||
"anchor",
|
||||
"future",
|
||||
"episode 9"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"type": "qa",
|
||||
"title": "Who was Titus?",
|
||||
"content": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"who is titus",
|
||||
"gentile",
|
||||
"paul",
|
||||
"co-worker",
|
||||
"crete",
|
||||
"new testament",
|
||||
"biography",
|
||||
"history",
|
||||
"faith"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"type": "qa",
|
||||
"title": "What does it mean to deny God by your works?",
|
||||
"content": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"keywords": [
|
||||
"deny god",
|
||||
"works",
|
||||
"titus 1:16",
|
||||
"detestable",
|
||||
"disobedient",
|
||||
"unfit",
|
||||
"profess",
|
||||
"actions",
|
||||
"hypocrisy",
|
||||
"false teaching",
|
||||
"episode 6"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"type": "qa",
|
||||
"title": "What does grace actually train us to do?",
|
||||
"content": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"keywords": [
|
||||
"grace",
|
||||
"trains",
|
||||
"titus 2:11",
|
||||
"titus 2:12",
|
||||
"paideuō",
|
||||
"ungodliness",
|
||||
"sensible",
|
||||
"upright",
|
||||
"godly",
|
||||
"obedience",
|
||||
"formation",
|
||||
"episode 8"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"type": "qa",
|
||||
"title": "What does Titus 1 teach about church leadership?",
|
||||
"content": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"keywords": [
|
||||
"leadership",
|
||||
"elder",
|
||||
"overseer",
|
||||
"titus 1:5",
|
||||
"titus 1:6",
|
||||
"titus 1:7",
|
||||
"titus 1:9",
|
||||
"church",
|
||||
"pastor",
|
||||
"qualify",
|
||||
"character",
|
||||
"household",
|
||||
"episode 4"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"type": "qa",
|
||||
"title": "Give me a summary of Titus 3:4-7",
|
||||
"content": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"keywords": [
|
||||
"titus 3:4",
|
||||
"titus 3:4-7",
|
||||
"titus 3:7",
|
||||
"summary",
|
||||
"gospel",
|
||||
"mercy",
|
||||
"salvation",
|
||||
"trinity",
|
||||
"justified",
|
||||
"heirs",
|
||||
"new birth",
|
||||
"renewal",
|
||||
"holy spirit",
|
||||
"episode 12"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"type": "qa",
|
||||
"title": "How should Christians engage with politics and public life?",
|
||||
"content": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"keywords": [
|
||||
"politics",
|
||||
"government",
|
||||
"Christians",
|
||||
"public life",
|
||||
"civic",
|
||||
"titus 3:1",
|
||||
"titus 3:2",
|
||||
"submit",
|
||||
"peaceable",
|
||||
"gentle",
|
||||
"social media",
|
||||
"culture",
|
||||
"episode 10"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{ "query": "who was titus", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 3 },
|
||||
{ "query": "who is paul", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 3 },
|
||||
{ "query": "tell me about paul in titus", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 4 },
|
||||
{ "query": "who was saul before his conversion", "expectedEntryId": "167fa083-5438-44a5-ac72-6dd379e937f5", "expectedTopK": 4 },
|
||||
{ "query": "who was titus and why was he in crete", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 12 },
|
||||
{ "query": "what bible translation does nate use", "expectedEntryId": "cb_001", "expectedTopK": 1 },
|
||||
{ "query": "does the podcast use bsb", "expectedEntryId": "cb_001", "expectedTopK": 2 },
|
||||
{ "query": "which bible version do you teach from", "expectedEntryId": "cb_001", "expectedTopK": 2 },
|
||||
{ "query": "how can i stay consistent with daily bible reading", "expectedEntryId": "cb_003", "expectedTopK": 2 },
|
||||
{ "query": "help me build a daily reading habit", "expectedEntryId": "cb_003", "expectedTopK": 3 },
|
||||
{ "query": "i keep missing devotion time what should i do", "expectedEntryId": "cb_003", "expectedTopK": 4 },
|
||||
{ "query": "how should christians engage with politics", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 2 },
|
||||
{ "query": "what does titus say about public life", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 3 },
|
||||
{ "query": "how can i be peaceable online", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 4 },
|
||||
{ "query": "give me a summary of titus 3:4-7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 1 },
|
||||
{ "query": "summarize titus 3 4 through 7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 12 },
|
||||
{ "query": "what does titus 3:4-7 teach about salvation", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "what is the blessed hope", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
|
||||
{ "query": "define blessed hope from titus 2:13", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
|
||||
{ "query": "what does makaria elpis mean", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 3 },
|
||||
{ "query": "what does grace train us to do", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 2 },
|
||||
{ "query": "how does grace teach us to say no to ungodliness", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 3 },
|
||||
{ "query": "what is paideuo in titus 2", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
|
||||
{ "query": "how do i submit a question to nate", "expectedEntryId": "cb_007", "expectedTopK": 1 },
|
||||
{ "query": "where can i send bible questions", "expectedEntryId": "cb_007", "expectedTopK": 12 },
|
||||
{ "query": "how do i contact nate", "expectedEntryId": "cb_007", "expectedTopK": 2 },
|
||||
{ "query": "what does titus 1 teach about church leadership", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 2 },
|
||||
{ "query": "elder qualifications in titus 1", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 12 },
|
||||
{ "query": "what should an overseer be like", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 3 },
|
||||
{ "query": "what did paul say about elders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 4 },
|
||||
{ "query": "episode on faithful leaders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 3 },
|
||||
{ "query": "titus 1:5-9 overview", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 12 },
|
||||
{ "query": "what are false teachers doing in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
|
||||
{ "query": "what is the circumcision group in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
|
||||
{ "query": "empty talk and deception in titus 1", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 4 },
|
||||
{ "query": "what does it mean to deny god by your works", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 2 },
|
||||
{ "query": "they profess to know god but deny him by actions", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 3 },
|
||||
{ "query": "detestable disobedient unfit meaning", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 4 },
|
||||
{ "query": "episode about grace training us", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 2 },
|
||||
{ "query": "titus 2:11-12 episode", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
|
||||
{ "query": "how should i share faith with skeptical family", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 2 },
|
||||
{ "query": "evangelize skeptical relatives", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 4 },
|
||||
{ "query": "where can i listen to the podcast", "expectedEntryId": "cb_006", "expectedTopK": 2 },
|
||||
{ "query": "how do i find verse by verse with nate on apple podcasts", "expectedEntryId": "cb_006", "expectedTopK": 3 },
|
||||
{ "query": "what is verse by verse with nate podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
|
||||
{ "query": "tell me about the podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
|
||||
{ "query": "how do i approach a difficult passage", "expectedEntryId": "cb_002", "expectedTopK": 2 },
|
||||
{ "query": "what should i do with confusing bible verses", "expectedEntryId": "cb_002", "expectedTopK": 3 },
|
||||
{ "query": "prayer and quiet time advice", "expectedEntryId": "cb_008", "expectedTopK": 2 },
|
||||
{ "query": "how to start a quiet time", "expectedEntryId": "cb_008", "expectedTopK": 3 },
|
||||
{ "query": "what is episode 9 about", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 12 },
|
||||
{ "query": "living between two appearings", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 2 },
|
||||
{ "query": "what is episode 12", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "the gospel in one paragraph", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "what is episode 14 about", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 },
|
||||
{ "query": "grace where it starts and where it ends", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 }
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"id": "cb_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What Bible translation do you use?",
|
||||
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"firstName": "Nate",
|
||||
"question": "How do you approach a difficult or confusing Bible passage?",
|
||||
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I stay consistent with daily Bible reading?",
|
||||
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I submit a question to Nate?",
|
||||
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"isApproved": true,
|
||||
"topic": "Podcast"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How can I share my faith with skeptical family?",
|
||||
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What is the blessed hope?",
|
||||
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Who was Titus?",
|
||||
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does it mean to deny God by your works?",
|
||||
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does grace actually train us to do?",
|
||||
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does Titus 1 teach about church leadership?",
|
||||
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Give me a summary of Titus 3:4-7",
|
||||
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How should Christians engage with politics and public life?",
|
||||
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -7,6 +7,8 @@ services:
|
||||
- "4173:4173"
|
||||
environment:
|
||||
- PORT=4173
|
||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||
- RESEND_API_KEY=`set your Resend API key here`
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DATA_FILE=/app/data/admin-content.json
|
||||
SEED_FILE=/app/data-seed/admin-content.json
|
||||
DATA_DIR=/app/data
|
||||
SEED_DIR=/app/data-seed
|
||||
|
||||
# Ensure the data directory exists (in case the volume was not mounted)
|
||||
mkdir -p /app/data
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Only seed from image defaults when no live data file exists yet.
|
||||
# Seed each default JSON file only when missing.
|
||||
# This runs on first boot or a fresh volume, but never overwrites existing data.
|
||||
if [ ! -f "$DATA_FILE" ] && [ -f "$SEED_FILE" ]; then
|
||||
echo "[siteforge] No admin-content.json found. Seeding from image defaults..."
|
||||
cp "$SEED_FILE" "$DATA_FILE"
|
||||
if [ -d "$SEED_DIR" ]; then
|
||||
for seed_file in "$SEED_DIR"/*.json; do
|
||||
[ -f "$seed_file" ] || continue
|
||||
|
||||
base_name=$(basename "$seed_file")
|
||||
target_file="$DATA_DIR/$base_name"
|
||||
|
||||
if [ ! -f "$target_file" ]; then
|
||||
echo "[siteforge] No $base_name found. Seeding from image defaults..."
|
||||
cp "$seed_file" "$target_file"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
exec node server.js
|
||||
|
||||
@@ -4,7 +4,24 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Siteforge</title>
|
||||
<meta name="description" content="Verse by Verse with Nate — A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
|
||||
<title>Verse by Verse with Nate</title>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Verse by Verse with Nate" />
|
||||
<meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
|
||||
<meta property="og:image" content="https://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://versebyversewithnate.us" />
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate" />
|
||||
<link rel="canonical" href="https://versebyversewithnate.us" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -863,6 +864,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -2239,6 +2246,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -4309,6 +4322,12 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -4582,6 +4601,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz",
|
||||
"integrity": "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postal-mime": "2.7.4",
|
||||
"svix": "1.88.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-email/render": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-email/render": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -4866,6 +4906,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -4961,6 +5011,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.88.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.88.0.tgz",
|
||||
"integrity": "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -5246,6 +5306,19 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"api": "node server.js",
|
||||
"api": "node --env-file=.env server.js",
|
||||
"dev:full": "concurrently \"npm:api\" \"npm:dev\"",
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node server.js",
|
||||
"start": "node --env-file=.env server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
@@ -18,7 +19,8 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
Disallow: /thanks
|
||||
Disallow: /subscribe/thanks
|
||||
|
||||
Sitemap: https://versebyversewithnate.us/sitemap.xml
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/start-here</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/questions</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/subscribe</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/privacy</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/terms</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const BASE = "/Users/nate.emmert/Documents/github/Siteforge/Verse by Verse with Nate Complete Series"
|
||||
const CHATBOT_FILE = "/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json"
|
||||
|
||||
const FILES = [
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode02.docx`, ep: 2 },
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode03.docx`, ep: 3 },
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode04_Updated.docx`, ep: 4 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode05.docx`, ep: 5 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode06_expanded.docx`, ep: 6 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode07.docx`, ep: 7 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode08.docx`, ep: 8 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode09.docx`, ep: 9 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode10.docx`, ep: 10 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode11.docx`, ep: 11 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode12.docx`, ep: 12 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode13.docx`, ep: 13 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode14.docx`, ep: 14 },
|
||||
]
|
||||
|
||||
function extractText(filePath) {
|
||||
const xml = execSync(`unzip -p "${filePath}" word/document.xml 2>/dev/null`, { encoding: 'utf8' })
|
||||
return xml
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseEpisode(raw, epNum) {
|
||||
// Extract episode subtitle and passage reference from header
|
||||
const headerMatch = raw.match(/EPISODE\s+\d+\s*[—\-\u2013\u2014]+\s*(.+?)\s+(Titus\s+[\d:]+(?:\s*[\-\u2013\u2014]+\s*[\d:]+)?)\s*·/i)
|
||||
const subtitle = headerMatch ? headerMatch[1].trim().replace(/\s+/g, ' ') : ''
|
||||
const passageRef = headerMatch ? headerMatch[2].trim() : 'Titus'
|
||||
const episodeTitle = `Episode ${epNum} — ${subtitle || 'Verse by Verse with Nate'}`
|
||||
|
||||
// Find where the actual teaching content starts
|
||||
let contentStart = raw.indexOf('SEGMENT 1')
|
||||
if (contentStart === -1) contentStart = raw.indexOf('WHO WAS PAUL')
|
||||
if (contentStart === -1) contentStart = raw.indexOf('COLD OPEN')
|
||||
if (contentStart === -1) contentStart = 400
|
||||
|
||||
const rawContent = raw.slice(contentStart, contentStart + 4000)
|
||||
const content = rawContent
|
||||
.replace(/\[[^\]]{0,100}\]/g, '') // remove [stage directions]
|
||||
.replace(/[✝🎙️📖💬🧠💡🔑✅◀▶]/gu, '') // remove emoji
|
||||
.replace(/SEGMENT\s+\d+\s*[—\-\u2013]+\s*/g, '\n\n') // turn SEGMENT headers into breaks
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
|
||||
// Build keyword list
|
||||
const verseRefs = [...new Set((raw.match(/Titus\s+\d+:\d+/g) || []))].slice(0, 5).map(k => k.toLowerCase())
|
||||
const titleWords = subtitle.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 3)
|
||||
const keywords = [...new Set([
|
||||
'titus', `episode ${epNum}`, passageRef.toLowerCase(),
|
||||
...verseRefs, ...titleWords
|
||||
])].slice(0, 20)
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'episode',
|
||||
title: `${episodeTitle} (${passageRef})`,
|
||||
content: content.slice(0, 3900),
|
||||
keywords,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing entries (keep the 8 hand-written ones)
|
||||
const existing = JSON.parse(readFileSync(CHATBOT_FILE, 'utf8'))
|
||||
// Remove any previously generated episode entries to avoid duplication
|
||||
const baseEntries = existing.filter(e => e.type !== 'episode')
|
||||
|
||||
const newEntries = []
|
||||
for (const { file, ep } of FILES) {
|
||||
try {
|
||||
const raw = extractText(file)
|
||||
const entry = parseEpisode(raw, ep)
|
||||
newEntries.push(entry)
|
||||
console.log(`✓ Ep ${ep}: ${entry.title.slice(0, 80)}`)
|
||||
} catch (err) {
|
||||
console.error(`✗ Ep ${ep}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = [...baseEntries, ...newEntries]
|
||||
writeFileSync(CHATBOT_FILE, JSON.stringify(combined, null, 2), 'utf8')
|
||||
console.log(`\nDone. ${newEntries.length} episode entries added. Total: ${combined.length}`)
|
||||
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
const dataPath = path.join(root, 'data', 'chatbot-content.json')
|
||||
const evalPath = path.join(root, 'data', 'chatbot-eval.json')
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a','an','the','is','are','was','were','be','been','being','have','has','had','do','does','did',
|
||||
'will','would','could','should','may','might','shall','can','i','you','he','she','it','we','they',
|
||||
'me','him','her','us','them','my','your','his','its','our','their','this','that','these','those',
|
||||
'and','but','or','nor','so','yet','for','of','in','on','at','to','from','with','by','about',
|
||||
'what','how','why','when','where','who','which','if','then','than','as','just','not',
|
||||
])
|
||||
|
||||
const TOKEN_ALIASES = {
|
||||
bible: ['translation', 'version', 'scripture', 'bsb', 'berean'],
|
||||
translation: ['version', 'bsb', 'berean', 'bible'],
|
||||
version: ['translation', 'bsb', 'berean', 'bible'],
|
||||
elders: ['elder', 'leadership', 'leaders', 'overseer', 'pastor'],
|
||||
leadership: ['elders', 'elder', 'overseer', 'leaders'],
|
||||
grace: ['salvation', 'saved', 'godliness', 'mercy'],
|
||||
salvation: ['saved', 'grace', 'mercy', 'gospel'],
|
||||
saved: ['salvation', 'grace', 'mercy', 'gospel'],
|
||||
hope: ['blessed', 'appearing', 'return', 'coming'],
|
||||
politics: ['public', 'government', 'authorities'],
|
||||
}
|
||||
|
||||
function tokenize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 2 && !STOP_WORDS.has(token))
|
||||
}
|
||||
|
||||
function expandTokens(tokens) {
|
||||
const expanded = new Set(tokens)
|
||||
for (const token of tokens) {
|
||||
const aliases = TOKEN_ALIASES[token] ?? []
|
||||
for (const alias of aliases) {
|
||||
for (const aliasToken of tokenize(alias)) expanded.add(aliasToken)
|
||||
}
|
||||
}
|
||||
return [...expanded]
|
||||
}
|
||||
|
||||
function literalTerms(text) {
|
||||
return [...new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9:\-\s']/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(term => term.trim())
|
||||
.filter(term => term.length >= 2 && !STOP_WORDS.has(term)),
|
||||
)]
|
||||
}
|
||||
|
||||
function scoreEntry(entry, query) {
|
||||
const indexText = `${entry.title} ${entry.content} ${entry.keywords.join(' ')}`.toLowerCase()
|
||||
const queryTokens = expandTokens(tokenize(query))
|
||||
const terms = literalTerms(query)
|
||||
|
||||
let score = 0
|
||||
|
||||
for (const token of queryTokens) {
|
||||
if (entry.title.toLowerCase().includes(token)) score += 4
|
||||
else if (indexText.includes(token)) score += 2
|
||||
}
|
||||
|
||||
for (const term of terms) {
|
||||
if (entry.title.toLowerCase().includes(term)) score += 2
|
||||
else if (indexText.includes(term)) score += 1
|
||||
}
|
||||
|
||||
const phrase = query.toLowerCase().replace(/[^a-z0-9:\-\s']/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (phrase.length > 6) {
|
||||
if (entry.title.toLowerCase().includes(phrase)) score += 10
|
||||
else if (indexText.includes(phrase)) score += 6
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [contentRaw, evalRaw] = await Promise.all([
|
||||
fs.readFile(dataPath, 'utf8'),
|
||||
fs.readFile(evalPath, 'utf8'),
|
||||
])
|
||||
|
||||
const entries = JSON.parse(contentRaw)
|
||||
const tests = JSON.parse(evalRaw)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
|
||||
for (const test of tests) {
|
||||
const ranked = entries
|
||||
.map(entry => ({ entry, score: scoreEntry(entry, test.query) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const topK = ranked.slice(0, test.expectedTopK)
|
||||
const hit = topK.some(item => item.entry.id === test.expectedEntryId)
|
||||
|
||||
if (hit) {
|
||||
pass += 1
|
||||
continue
|
||||
}
|
||||
|
||||
failures.push({
|
||||
query: test.query,
|
||||
expectedEntryId: test.expectedEntryId,
|
||||
expectedTopK: test.expectedTopK,
|
||||
actualTop: topK.map(item => ({ id: item.entry.id, title: item.entry.title, score: item.score })),
|
||||
})
|
||||
}
|
||||
|
||||
const total = tests.length
|
||||
const pct = ((pass / total) * 100).toFixed(1)
|
||||
|
||||
console.log(`Chatbot eval: ${pass}/${total} (${pct}%) passed`)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\nFailures:')
|
||||
for (const failure of failures) {
|
||||
console.log(`- Query: ${failure.query}`)
|
||||
console.log(` Expected: ${failure.expectedEntryId} in top ${failure.expectedTopK}`)
|
||||
console.log(` Actual: ${failure.actualTop.map(item => `${item.id} (${item.score})`).join(', ')}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
const path = '/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json'
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||
|
||||
const fixes = {
|
||||
2: { title: 'Episode 2 — Introduction to Titus (Background & Overview)', extra: ['introduction', 'background', 'overview', 'crete', 'letter'] },
|
||||
5: { title: 'Episode 5 — The Danger of Empty Words (Titus 1:10–13a)', extra: ['danger', 'empty', 'words', 'false', 'teacher', 'titus 1:10', 'titus 1:13'] },
|
||||
6: { title: 'Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)', extra: ['deny', 'claim', 'believe', 'titus 1:13', 'titus 1:16'] },
|
||||
14: { title: 'Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)', extra: ['grace', 'starts', 'ends', 'review', 'titus 3:12', 'titus 3:15'] },
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const entry of data) {
|
||||
const m = entry.title.match(/^Episode (\d+)/)
|
||||
if (!m) continue
|
||||
const ep = Number(m[1])
|
||||
if (fixes[ep]) {
|
||||
entry.title = fixes[ep].title
|
||||
entry.keywords = [...new Set([...entry.keywords, ...fixes[ep].extra])].slice(0, 20)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8')
|
||||
console.log(`Fixed ${count} entries. Total: ${data.length}`)
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { parseCookies } from './helpers.js'
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||
const adminSessions = new Map()
|
||||
|
||||
function cookieFlags() {
|
||||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
}
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(String(value)).digest('hex')
|
||||
}
|
||||
|
||||
export function isAdminPasswordConfigured() {
|
||||
return Boolean(ADMIN_PASSWORD)
|
||||
}
|
||||
|
||||
export function validateAdminPasswordSetup() {
|
||||
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('ADMIN_PASSWORD is required in production.')
|
||||
}
|
||||
|
||||
if (!isAdminPasswordConfigured()) {
|
||||
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdminPasswordValid(password) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
return sha256(password) === sha256(ADMIN_PASSWORD)
|
||||
}
|
||||
|
||||
export function createAdminSession() {
|
||||
const token = randomUUID()
|
||||
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteAdminSession(token) {
|
||||
if (token) {
|
||||
adminSessions.delete(token)
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAdminSession(req) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||
if (!sessionToken) return false
|
||||
|
||||
const expiresAt = adminSessions.get(sessionToken)
|
||||
if (!expiresAt || expiresAt <= Date.now()) {
|
||||
adminSessions.delete(sessionToken)
|
||||
return false
|
||||
}
|
||||
|
||||
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
export function setAdminSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function requireAdminAuth(req, res, next) {
|
||||
if (!isValidAdminSession(req)) {
|
||||
res.status(401).json({ message: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export const DEFAULT_REDIRECT_RULES = [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_SEO = {
|
||||
title: 'Verse by Verse with Nate',
|
||||
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
}
|
||||
|
||||
export const DEFAULT_LEGAL = {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'Content on this site is for informational and ministry purposes.',
|
||||
'External links are provided for convenience and are subject to third-party policies.',
|
||||
'By using this site, you agree to lawful use and respectful communication.',
|
||||
],
|
||||
}
|
||||
|
||||
export const DEFAULT_PODCAST_FEATURED_LINKS = []
|
||||
export const DEFAULT_PUBLISH_STATE = {
|
||||
draftUpdatedAt: null,
|
||||
publishedAt: null,
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function splitName(fullName) {
|
||||
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
|
||||
return {
|
||||
firstName: parts[0] ?? '',
|
||||
lastName: parts.slice(1).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('/')) return trimmed
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeRedirectPath(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
const normalized = withSlash.replace(/\/+/g, '/')
|
||||
if (normalized === '/') return ''
|
||||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeSitemapPath(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed === '/') return '/'
|
||||
return normalizeRedirectPath(trimmed)
|
||||
}
|
||||
|
||||
export function sanitizeRedirectRules(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const item of source) {
|
||||
const pathValue = normalizeRedirectPath(item?.path)
|
||||
const target = sanitizeUrl(item?.target)
|
||||
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
||||
if (!pathValue || !target) continue
|
||||
if (seen.has(pathValue)) continue
|
||||
seen.add(pathValue)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
path: pathValue,
|
||||
target,
|
||||
statusCode,
|
||||
})
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
||||
}
|
||||
|
||||
export function sanitizeFeaturedLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => {
|
||||
const discussionQuestions = Array.isArray(item.discussionQuestions)
|
||||
? item.discussionQuestions
|
||||
.filter(question => typeof question === 'string')
|
||||
.map(question => question.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30)
|
||||
: []
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
embedUrl: sanitizeUrl(item.embedUrl),
|
||||
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
|
||||
discussionQuestions,
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
item =>
|
||||
item.title ||
|
||||
item.summary ||
|
||||
item.url ||
|
||||
item.embedUrl ||
|
||||
item.showNotes ||
|
||||
item.discussionQuestions.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeCustomLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => {
|
||||
const placement =
|
||||
item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources'
|
||||
? item.placement
|
||||
: 'footer'
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
imageUrl: sanitizeUrl(item.imageUrl),
|
||||
placement,
|
||||
}
|
||||
})
|
||||
.filter(item => item.label && item.url)
|
||||
}
|
||||
|
||||
function sanitizeCustomBlocks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||||
}))
|
||||
.filter(item => item.heading || item.body)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeriesResourceLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
}))
|
||||
.filter(item => item.label && item.url)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeriesNotes(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||||
}))
|
||||
.filter(item => item.heading || item.body)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeries(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
|
||||
imageUrl: sanitizeUrl(item.imageUrl),
|
||||
listenUrl: sanitizeUrl(item.listenUrl),
|
||||
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
|
||||
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
|
||||
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
|
||||
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
|
||||
notes: sanitizeArchivedSeriesNotes(item.notes),
|
||||
}))
|
||||
.filter(
|
||||
item =>
|
||||
item.title ||
|
||||
item.description ||
|
||||
item.resourceLinks.length > 0 ||
|
||||
item.notes.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeSiteContent(siteContent) {
|
||||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
|
||||
|
||||
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
|
||||
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
|
||||
|
||||
return {
|
||||
...siteContent,
|
||||
customLinks: sanitizeCustomLinks(siteContent.customLinks),
|
||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||
seo: {
|
||||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
||||
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
||||
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
|
||||
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
|
||||
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
|
||||
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
|
||||
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
|
||||
sitemapPaths: Array.isArray(seo.sitemapPaths)
|
||||
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
|
||||
: [...DEFAULT_SEO.sitemapPaths],
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
|
||||
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
|
||||
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||
: [...DEFAULT_LEGAL.privacyBody],
|
||||
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
|
||||
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
|
||||
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||
: [...DEFAULT_LEGAL.termsBody],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
|
||||
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
|
||||
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
|
||||
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
|
||||
const value = maybeRelativePath.trim()
|
||||
if (/^https?:\/\//i.test(value)) return value
|
||||
if (value.startsWith('/')) return `${root}${value}`
|
||||
return `${root}/${value}`
|
||||
}
|
||||
|
||||
export function injectSeoIntoHtml(html, siteContent) {
|
||||
const seo = siteContent?.seo ?? DEFAULT_SEO
|
||||
const title = seo.title || DEFAULT_SEO.title
|
||||
const description = seo.description || DEFAULT_SEO.description
|
||||
const ogTitle = seo.ogTitle || title
|
||||
const ogDescription = seo.ogDescription || description
|
||||
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
|
||||
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
|
||||
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
|
||||
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
|
||||
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
|
||||
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
|
||||
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
|
||||
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
|
||||
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
|
||||
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
|
||||
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
|
||||
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
|
||||
}
|
||||
|
||||
export function normalizeAssetBaseName(name) {
|
||||
if (typeof name !== 'string') return `upload-${Date.now()}`
|
||||
const cleaned = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
return cleaned || `upload-${Date.now()}`
|
||||
}
|
||||
|
||||
export function inferImageExtensionFromDataUrl(dataUrl) {
|
||||
if (typeof dataUrl !== 'string') return null
|
||||
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
|
||||
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
|
||||
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
|
||||
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeIp(rawIp) {
|
||||
if (!rawIp) return 'unknown'
|
||||
|
||||
let ip = String(rawIp).trim()
|
||||
|
||||
if (ip.includes(',')) {
|
||||
ip = ip.split(',')[0].trim()
|
||||
}
|
||||
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7)
|
||||
}
|
||||
|
||||
if (ip === '::1') {
|
||||
ip = '127.0.0.1'
|
||||
}
|
||||
|
||||
return ip || 'unknown'
|
||||
}
|
||||
|
||||
export function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for']
|
||||
if (forwarded) {
|
||||
return normalizeIp(forwarded)
|
||||
}
|
||||
return normalizeIp(req.ip)
|
||||
}
|
||||
|
||||
export function parseCookies(cookieHeader) {
|
||||
if (!cookieHeader) return {}
|
||||
|
||||
return cookieHeader
|
||||
.split(';')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((acc, part) => {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx === -1) return acc
|
||||
const key = part.slice(0, idx).trim()
|
||||
const value = part.slice(idx + 1).trim()
|
||||
try {
|
||||
acc[key] = decodeURIComponent(value)
|
||||
} catch {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function hasVisitorConsent(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
return cookies['vbn_analytics_consent'] === 'yes'
|
||||
}
|
||||
|
||||
export function setConsentCookie(res, consent) {
|
||||
const value = consent ? 'yes' : 'no'
|
||||
const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`)
|
||||
}
|
||||
|
||||
export function isPrivateOrLocalIp(ip) {
|
||||
return (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === 'localhost' ||
|
||||
ip.startsWith('10.') ||
|
||||
ip.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
|
||||
ip.startsWith('fc') ||
|
||||
ip.startsWith('fd') ||
|
||||
ip.startsWith('fe80:') ||
|
||||
ip === 'unknown'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface ContactFields {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: string
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
const navigate = useNavigate()
|
||||
const [fields, setFields] = useState<ContactFields>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
message: '',
|
||||
messageType: 'question',
|
||||
})
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('submitting')
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/thanks')
|
||||
} catch {
|
||||
setErrorMsg('Could not connect. Please try again later.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
||||
<input
|
||||
type="text"
|
||||
className="contact-honeypot"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={honey}
|
||||
onChange={e => setHoney(e.target.value)}
|
||||
/>
|
||||
<label>
|
||||
First Name
|
||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Last Name
|
||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Message Type
|
||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
||||
<option value="question">Bible Question</option>
|
||||
<option value="testimony">Testimony</option>
|
||||
<option value="topic">Topic Request</option>
|
||||
<option value="general">General Message</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Message
|
||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||
</label>
|
||||
<label className="contact-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subscribe}
|
||||
onChange={e => setSubscribe(e.target.checked)}
|
||||
/>
|
||||
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||
</label>
|
||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
interface PublicQuestion {
|
||||
id: string
|
||||
firstName: string
|
||||
question: string
|
||||
answer: string
|
||||
topic?: string
|
||||
}
|
||||
|
||||
const QA_PAGE_SIZE = 6
|
||||
|
||||
function tokenizeForRelated(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 3)
|
||||
}
|
||||
|
||||
export default function QASection() {
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
||||
const [page, setPage] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/questions')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||||
.then(data => {
|
||||
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
||||
|
||||
const filteredQuestions = questions.filter(q => {
|
||||
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesTopic && matchesSearch
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(state => ({ ...state, [id]: !state[id] }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleTopic = (topic: string | null) => {
|
||||
setSelectedTopic(topic)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const getRelatedQuestions = (current: PublicQuestion) => {
|
||||
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
||||
|
||||
return questions
|
||||
.filter(candidate => candidate.id !== current.id)
|
||||
.map(candidate => {
|
||||
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
||||
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
||||
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
||||
const score = overlap + (sameTopic ? 5 : 0)
|
||||
return { candidate, score }
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map(item => item.candidate)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||
<div className="section-inner">
|
||||
<h2 className="section-heading">
|
||||
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="qa-no-results">Loading questions…</p>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-filters">
|
||||
<div className="qa-topics">
|
||||
<button
|
||||
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{topics.map(topic => (
|
||||
<button
|
||||
key={topic}
|
||||
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(topic)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qa-search">
|
||||
<label>
|
||||
<span className="visually-hidden">Search Questions</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions…"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-cards">
|
||||
{pagedQuestions.map(question => (
|
||||
<div key={question.id} className="qa-card-scene">
|
||||
<div
|
||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
||||
onClick={() => toggleExpanded(question.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!!expanded[question.id]}
|
||||
aria-label={question.question}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
|
||||
}}
|
||||
>
|
||||
<div className="qa-card-face qa-card-front">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<p className="qa-question-text">{question.question}</p>
|
||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
<div className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<p className="qa-answer-text">{question.answer}</p>
|
||||
{getRelatedQuestions(question).length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
<div className="qa-related-list">
|
||||
{getRelatedQuestions(question).map(related => (
|
||||
<button
|
||||
key={related.id}
|
||||
type="button"
|
||||
className="qa-related-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTopic(null)
|
||||
handleSearch(related.question)
|
||||
setExpanded({ [related.id]: true })
|
||||
}}
|
||||
>
|
||||
{related.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
|
||||
— Answered by Nate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="qa-pagination">
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span className="qa-page-info">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
export interface CustomLink {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
placement: 'platforms' | 'footer' | 'resources'
|
||||
imageUrl?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface CustomBlock {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesResourceLink {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesNote {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeries {
|
||||
id: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
listenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
resourceLinks: ArchivedSeriesResourceLink[]
|
||||
notes: ArchivedSeriesNote[]
|
||||
}
|
||||
|
||||
export interface RedirectRule {
|
||||
id: string
|
||||
path: string
|
||||
target: string
|
||||
statusCode: 301 | 302
|
||||
}
|
||||
|
||||
export interface PodcastFeaturedLink {
|
||||
id: string
|
||||
title: string
|
||||
episodeNumber?: string
|
||||
summary: string
|
||||
url: string
|
||||
embedUrl?: string
|
||||
showNotes?: string
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
|
||||
export interface SeoSettings {
|
||||
title: string
|
||||
description: string
|
||||
ogTitle: string
|
||||
ogDescription: string
|
||||
ogImage: string
|
||||
canonicalUrl: string
|
||||
robotsPolicy: string
|
||||
sitemapPaths: string[]
|
||||
}
|
||||
|
||||
export interface LegalSettings {
|
||||
privacyTitle: string
|
||||
privacyBody: string[]
|
||||
termsTitle: string
|
||||
termsBody: string[]
|
||||
}
|
||||
|
||||
export interface SiteContent {
|
||||
eyebrow: string
|
||||
heroTagline: string
|
||||
startHereHeading: string
|
||||
startHereIntro: string
|
||||
startHereStep1Title: string
|
||||
startHereStep1Body: string
|
||||
startHereStep1Cta: string
|
||||
startHereStep2Title: string
|
||||
startHereStep2Body: string
|
||||
startHereStep2Cta: string
|
||||
startHereStep3Title: string
|
||||
startHereStep3Body: string
|
||||
startHereStep3Cta: string
|
||||
aboutShowHeading: string
|
||||
aboutShowP1: string
|
||||
aboutShowP2: string
|
||||
aboutNate: string
|
||||
aboutPhotoUrl: string
|
||||
aboutVerseArtUrl: string
|
||||
contactPhotoUrl: string
|
||||
seriesLabel: string
|
||||
seriesTitle: string
|
||||
seriesDescription: string
|
||||
seriesImageUrl: string
|
||||
seriesListenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
shareHeading: string
|
||||
shareP: string
|
||||
customLinks: CustomLink[]
|
||||
customBlocks: CustomBlock[]
|
||||
archivedSeries: ArchivedSeries[]
|
||||
redirects: RedirectRule[]
|
||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||
seo: SeoSettings
|
||||
legal: LegalSettings
|
||||
}
|
||||
|
||||
export const DEFAULTS: SiteContent = {
|
||||
eyebrow: 'A Journey Through Scripture',
|
||||
heroTagline: "Exploring God's Word one verse at a time",
|
||||
startHereHeading: 'New Here? Start Here',
|
||||
startHereIntro:
|
||||
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
|
||||
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
|
||||
startHereStep1Body:
|
||||
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
|
||||
startHereStep1Cta: 'Open Episodes',
|
||||
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
|
||||
startHereStep2Body:
|
||||
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
|
||||
startHereStep2Cta: 'Go to Q&A',
|
||||
startHereStep3Title: 'Step 3: Ask Nate Directly',
|
||||
startHereStep3Body:
|
||||
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
|
||||
startHereStep3Cta: 'Submit a Question',
|
||||
aboutShowHeading: 'Depth. Clarity. Application.',
|
||||
aboutShowP1:
|
||||
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
|
||||
aboutShowP2:
|
||||
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
aboutNate:
|
||||
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
aboutPhotoUrl: '/images/nate-photo.jpeg',
|
||||
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
|
||||
contactPhotoUrl: '/images/nate-contact-photo.png',
|
||||
seriesLabel: 'Now Playing',
|
||||
seriesTitle: 'Study of Titus: Sound Doctrine',
|
||||
seriesDescription:
|
||||
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
seriesImageUrl: '/images/titus-cover.png',
|
||||
seriesListenUrl: '/spotify',
|
||||
studyGuideTitle: 'Companion Study Guide',
|
||||
studyGuideDescription:
|
||||
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
|
||||
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
|
||||
shareHeading: 'Help one more person hear the Word this week.',
|
||||
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
||||
customLinks: [],
|
||||
customBlocks: [],
|
||||
archivedSeries: [],
|
||||
redirects: [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
],
|
||||
podcastFeaturedLinks: [],
|
||||
seo: {
|
||||
title: 'Verse by Verse with Nate',
|
||||
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'Content on this site is for informational and ministry purposes.',
|
||||
'External links are provided for convenience and are subject to third-party policies.',
|
||||
'By using this site, you agree to lawful use and respectful communication.',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -29,243 +29,29 @@ export type Project = {
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
slug: 'weather',
|
||||
title: 'NeCloud Weather',
|
||||
domain: 'weather.necloud.us',
|
||||
url: 'https://weather.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/weather/main/README.md',
|
||||
category: 'Weather',
|
||||
slug: 'verse-by-verse',
|
||||
title: 'Verse by Verse with Nate',
|
||||
domain: 'spotify.com',
|
||||
url: 'https://creators.spotify.com/pod/profile/nmemmert/',
|
||||
category: 'Podcast',
|
||||
access: 'Public',
|
||||
ownership: 'built',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Weather dashboard with hourly forecasts, radar, and practical daily details.',
|
||||
summary: 'A verse-by-verse podcast from Nate Emmert focused on Scripture, story, and practical faith application.',
|
||||
details:
|
||||
'The homepage highlights Open-Meteo and RainViewer data, current conditions, high/low, daylight windows, and quick bring-along suggestions.',
|
||||
features: ['Hourly forecast', 'Radar view', 'Wind and atmosphere panel'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Fast weather checks with useful details at a glance.',
|
||||
problem:
|
||||
'Most weather tools either feel too basic or too noisy when you only want quick, actionable updates.',
|
||||
solution:
|
||||
'NeCloud Weather combines clean current conditions, practical recommendation blocks, and rapid forecast scanning in one view.',
|
||||
stack: ['React', 'TypeScript', 'Open-Meteo', 'RainViewer'],
|
||||
highlights: ['Location search', 'Hero weather card', '24-hour timeline', 'Radar shortcut'],
|
||||
nextSteps: ['Add severe weather alerts', 'Save favorite locations', 'Add sunrise trend charts'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the weather project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'capsule',
|
||||
title: 'Capsule',
|
||||
domain: 'capsule.necloud.us',
|
||||
url: 'https://capsule.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/capsule/main/README.md',
|
||||
category: 'Finance',
|
||||
access: 'Mixed',
|
||||
iconUrl: 'https://capsule.necloud.us/images/capsule-logo.svg',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Smart envelope budgeting app focused on planning and income allocation.',
|
||||
details:
|
||||
'The site presents guided setup, envelope-based spending controls, and paycheck auto-allocation to keep budgets structured.',
|
||||
features: ['Envelope budgeting', 'Income auto-allocation', 'Offline-ready data'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A practical budgeting workflow built around envelope control.',
|
||||
problem:
|
||||
'Traditional budget tools can feel rigid or overwhelming for everyday spending decisions.',
|
||||
solution:
|
||||
'Capsule uses guided setup and smart allocation so users can organize spending with clear envelope categories.',
|
||||
stack: ['React', 'TypeScript', 'Local-first storage'],
|
||||
highlights: ['Guided setup wizard', 'Envelope categories', 'Auto-allocation rules'],
|
||||
nextSteps: ['Add recurring bill templates', 'Import transaction feeds', 'Monthly budget snapshots'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the capsule project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'nexusbible',
|
||||
title: 'Nexus Bible',
|
||||
domain: 'nexusbible.necloud.us',
|
||||
url: 'https://nexusbible.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/nexusbible/main/README.md',
|
||||
category: 'Study',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Bible reader for reading, comparison, and study using Free Use Bible API data.',
|
||||
details:
|
||||
'The app exposes navigation, reader controls, bookmarks, and daily-focus tooling to support regular scripture study.',
|
||||
features: ['Reader modes', 'Bookmarks', 'Daily focus workflow'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Scripture reading designed for depth, rhythm, and clarity.',
|
||||
problem:
|
||||
'Switching between apps for reading, comparison, and planning breaks study flow.',
|
||||
solution:
|
||||
'Nexus Bible unifies reader tools, bookmarks, and daily focus controls for a single focused study experience.',
|
||||
stack: ['React', 'TypeScript', 'Free Use Bible API'],
|
||||
highlights: ['Reader mode', 'Passage navigation', 'Bookmarks', 'Daily focus entry points'],
|
||||
nextSteps: ['Add multi-translation compare view', 'Personal note collections', 'Reading streak tracking'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the nexusbible project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'prayer',
|
||||
title: 'Haver',
|
||||
domain: 'prayer.necloud.us',
|
||||
url: 'https://prayer.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/haver/main/README.md',
|
||||
category: 'Faith',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Prayer journal app for daily entries, requests, and answered-prayer tracking.',
|
||||
details:
|
||||
'Landing content emphasizes a minimalist prayer workflow with dashboards, calendar history, and Google Calendar reminder sync.',
|
||||
features: ['Prayer journal', 'Prayer dashboard', 'Calendar integration'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A calm daily prayer workflow with meaningful tracking.',
|
||||
problem:
|
||||
'Prayer journaling often lives across scattered notes and reminders, making consistency difficult.',
|
||||
solution:
|
||||
'Haver centralizes requests, daily entries, answered prayers, and reminder sync into one simple interface.',
|
||||
stack: ['React', 'Firebase', 'Google Calendar integration'],
|
||||
highlights: ['Prayer composer', 'Answered prayer tracking', 'Calendar view', 'Reminder sync'],
|
||||
nextSteps: ['Add shared prayer groups', 'Voice prayer entry', 'Weekly summary digest'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the prayer project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'skywatch',
|
||||
title: 'Skywatch',
|
||||
domain: 'skywatch.necloud.us',
|
||||
url: 'https://skywatch.necloud.us',
|
||||
category: 'Sky and Space',
|
||||
access: 'Public',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Skywatch project in your NeCloud stack.',
|
||||
details:
|
||||
'Live page content could not be scanned automatically because the endpoint responded with 401; this entry is currently a manual placeholder.',
|
||||
features: ['Manual metadata fallback', 'Live endpoint available via domain'],
|
||||
'Weekly episodes explore Scripture one verse at a time, mixing theological insight, devotional reflection, and real-life application.',
|
||||
features: ['Verse-by-verse teaching', 'Weekly podcast episodes', 'Practical reflection'],
|
||||
scanSource: 'manual',
|
||||
featured: {
|
||||
headline: 'Skywatch project overview placeholder.',
|
||||
problem: 'Automated scanning could not access this project because the endpoint returned 401.',
|
||||
solution:
|
||||
'This featured page is intentionally editable so you can replace placeholder copy with your exact app details.',
|
||||
stack: ['Update manually'],
|
||||
highlights: ['Placeholder content', 'Manual metadata fallback'],
|
||||
nextSteps: ['Add full description', 'Add stack details', 'Add feature highlights'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the skywatch project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'spellinghub',
|
||||
title: 'Spelling Hub',
|
||||
domain: 'spellinghub.necloud.us',
|
||||
url: 'https://spellinghub.necloud.us',
|
||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/spellinghub/main/README.md',
|
||||
category: 'Education',
|
||||
access: 'Mixed',
|
||||
ownership: 'built',
|
||||
status: 'Live',
|
||||
summary: 'Modern spelling practice application with account-based learning flows.',
|
||||
details:
|
||||
'Homepage messaging presents a clean practice platform with login and registration for user progress.',
|
||||
features: ['Practice workflows', 'User auth', 'Simple home dashboard'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Focused spelling practice with lightweight account flow.',
|
||||
headline: 'Podcast teaching shaped around verse-by-verse discovery.',
|
||||
problem:
|
||||
'Many spelling tools are cluttered and do not support structured, repeatable daily practice.',
|
||||
'Bible study and podcast listening often feel disconnected from each other and from everyday life.',
|
||||
solution:
|
||||
'Spelling Hub keeps onboarding simple and centers the product on practical, repeatable learning loops.',
|
||||
stack: ['React', 'TypeScript', 'Auth-backed workflow'],
|
||||
highlights: ['Login/register flow', 'Practice-first design', 'Simple navigation'],
|
||||
nextSteps: ['Add custom word lists', 'Teacher/student modes', 'Progress analytics dashboard'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the spellinghub project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'mealie',
|
||||
title: 'Mealie',
|
||||
domain: 'mealie.necloud.us',
|
||||
url: 'https://mealie.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Mixed',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Self-hosted recipe manager with categories, tags, and kitchen planning tools.',
|
||||
details:
|
||||
'Scanned content confirms a full Mealie interface with home feed, recipe browsing, and account login flow.',
|
||||
features: ['Recipe library', 'Tags and categories', 'User login'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Recipe organization and planning through your self-hosted stack.',
|
||||
problem:
|
||||
'Recipe collections get scattered across browser bookmarks and notes without centralized organization.',
|
||||
solution:
|
||||
'Your Mealie instance provides structured categories, tags, and searchable recipes for day-to-day meal planning.',
|
||||
stack: ['Mealie', 'Self-hosted deployment'],
|
||||
highlights: ['Recipe catalog', 'Category and tag views', 'Account login'],
|
||||
nextSteps: ['Add meal plan snapshots', 'Import bookmark recipes', 'Shopping list workflows'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the mealie project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'music',
|
||||
title: 'Swing Music',
|
||||
domain: 'music.necloud.us',
|
||||
url: 'https://music.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Mixed',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Self-hosted music streaming interface with folders, playlists, and favorites.',
|
||||
details:
|
||||
'Homepage scan identifies Swing Music with library navigation, playlists, favorites, and guest/standard access options.',
|
||||
features: ['Library browsing', 'Playlists', 'Guest mode'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'Swing Music streaming from your own infrastructure.',
|
||||
problem:
|
||||
'Centralized music services can limit control over personal libraries and hosting preferences.',
|
||||
solution:
|
||||
'Your Swing Music instance provides library browsing, playlists, and favorites with local hosting control.',
|
||||
stack: ['Swing Music', 'Self-hosted deployment'],
|
||||
highlights: ['Library navigation', 'Playlist support', 'Favorites', 'Guest mode'],
|
||||
nextSteps: ['Improve mobile now-playing UI', 'Add listening history', 'Playlist sharing links'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the music project.',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'seafile',
|
||||
title: 'Seafile Instance',
|
||||
domain: 'seafile.necloud.us',
|
||||
url: 'https://seafile.necloud.us',
|
||||
category: 'Self-hosted Tools',
|
||||
access: 'Login required',
|
||||
iconUrl: 'https://seafile.necloud.us/media/custom/mylogo.png',
|
||||
ownership: 'hosted',
|
||||
status: 'Live',
|
||||
summary: 'Your self-hosted Seafile instance for secure sync, sharing, and account-based access.',
|
||||
details:
|
||||
'Scanned landing view exposes the Seafile login portal with account signup and password reset features.',
|
||||
features: ['File sync platform', 'Account login', 'Password recovery'],
|
||||
scanSource: 'scanned',
|
||||
featured: {
|
||||
headline: 'A private file sync and sharing system you host yourself.',
|
||||
problem:
|
||||
'Cloud storage options often reduce control over data location and account governance.',
|
||||
solution:
|
||||
'Your Seafile instance gives you direct ownership of file sync, sharing access, and user account management.',
|
||||
stack: ['Seafile', 'Self-hosted deployment'],
|
||||
highlights: ['Secure login', 'Account management', 'Password reset flow'],
|
||||
nextSteps: ['Add team spaces', 'Automated backup checks', 'Storage usage dashboard'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the seafile project.',
|
||||
'Verse by Verse with Nate brings Scripture study and podcast conversation together in a format that is easy to follow and apply.',
|
||||
stack: ['Podcast hosting', 'Spotify', 'Verse-by-verse teaching'],
|
||||
highlights: ['Episode guides', 'Scripture focus', 'Practical application'],
|
||||
nextSteps: ['Add episode notes', 'Add guest interviews', 'Link transcripts and study resources'],
|
||||
updateNote: 'Edit this content in src/data/projects.ts under the Verse by Verse podcast project.',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function FacebookIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpotifyIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function YouTubeIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AmazonMusicIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +1,44 @@
|
||||
:root {
|
||||
--bg: #f6f0e8;
|
||||
--ink: #1f1d1a;
|
||||
--ink-muted: #5d584f;
|
||||
--line: #d8cdbf;
|
||||
--accent: #b04a1f;
|
||||
--hero-glow: #f7d8a6;
|
||||
--bg-radial: #fff5de;
|
||||
--bg-bottom: #efe4d6;
|
||||
--surface: #fff;
|
||||
--surface-soft: #fffcf7;
|
||||
--chip-bg: #f6f3ef;
|
||||
--pill-bg: #f1e7da;
|
||||
--pill-ink: #694627;
|
||||
--input-bg: #fffdf9;
|
||||
--display: 'Rockwell', 'Bookman Old Style', 'Georgia', serif;
|
||||
--body: 'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif;
|
||||
--radius-card: 18px;
|
||||
--radius-tile: 14px;
|
||||
--radius-panel: 24px;
|
||||
--radius-note: 12px;
|
||||
--radius-input: 10px;
|
||||
--radius-chip: 8px;
|
||||
--shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 4px 16px rgba(0, 0, 0, 0.10), 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
font-family: var(--body);
|
||||
line-height: 1.45;
|
||||
color: var(--ink);
|
||||
:root {
|
||||
--brand-black: #0a0a08;
|
||||
--brand-black-2: #0f0f0c;
|
||||
--brand-black-3: #1a1a15;
|
||||
--brand-border: #2a2518;
|
||||
--brand-gold: #c9a84c;
|
||||
--brand-gold-light: #e0c070;
|
||||
--brand-gold-dark: #8a6e28;
|
||||
--brand-warm-white: #f0ead8;
|
||||
--brand-muted: #7a7060;
|
||||
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
|
||||
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
|
||||
font-family: var(--brand-font-body);
|
||||
line-height: 1.5;
|
||||
color: var(--brand-warm-white);
|
||||
background: var(--brand-black);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root[data-theme='ocean'] {
|
||||
--bg: #eaf4f5;
|
||||
--ink: #122227;
|
||||
--ink-muted: #3d5b64;
|
||||
--line: #b7d4d9;
|
||||
--accent: #0f7f8c;
|
||||
--hero-glow: #8ed6df;
|
||||
--bg-radial: #d7eef2;
|
||||
--bg-bottom: #d8e9ec;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f4fbfc;
|
||||
--chip-bg: #ecf5f7;
|
||||
--pill-bg: #dceef1;
|
||||
--pill-ink: #1f5a63;
|
||||
--input-bg: #f8fcfd;
|
||||
}
|
||||
|
||||
:root[data-theme='midnight'] {
|
||||
--bg: #0f1722;
|
||||
--ink: #ecf0f6;
|
||||
--ink-muted: #a6b4c6;
|
||||
--line: #2a3a4d;
|
||||
--accent: #e06c2f;
|
||||
--hero-glow: #2f4663;
|
||||
--bg-radial: #1b2939;
|
||||
--bg-bottom: #111d2c;
|
||||
--surface: #172332;
|
||||
--surface-soft: #1a2737;
|
||||
--chip-bg: #26384c;
|
||||
--pill-bg: #31445b;
|
||||
--pill-ink: #d9e5f3;
|
||||
--input-bg: #121d2b;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-elevated: 0 6px 24px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
:root[data-theme='forest'] {
|
||||
--bg: #edf4ee;
|
||||
--ink: #1a2a20;
|
||||
--ink-muted: #446051;
|
||||
--line: #bfd6c4;
|
||||
--accent: #2f7a4e;
|
||||
--hero-glow: #9ad3ac;
|
||||
--bg-radial: #dceedd;
|
||||
--bg-bottom: #d9eadc;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6fbf7;
|
||||
--chip-bg: #edf6ef;
|
||||
--pill-bg: #dff0e3;
|
||||
--pill-ink: #24593a;
|
||||
--input-bg: #f8fcf8;
|
||||
}
|
||||
|
||||
:root[data-theme='sunset'] {
|
||||
--bg: #fff1e8;
|
||||
--ink: #3d1f1a;
|
||||
--ink-muted: #7d4a3d;
|
||||
--line: #f0c8b5;
|
||||
--accent: #d85b34;
|
||||
--hero-glow: #ffc08f;
|
||||
--bg-radial: #ffe1cc;
|
||||
--bg-bottom: #ffd8bf;
|
||||
--surface: #fffaf7;
|
||||
--surface-soft: #fff5ef;
|
||||
--chip-bg: #ffece0;
|
||||
--pill-bg: #ffe2d1;
|
||||
--pill-ink: #8a3f2a;
|
||||
--input-bg: #fff9f4;
|
||||
}
|
||||
|
||||
:root[data-theme='rose'] {
|
||||
--bg: #fcf0f4;
|
||||
--ink: #2d1825;
|
||||
--ink-muted: #7d4b63;
|
||||
--line: #f0c4d8;
|
||||
--accent: #b8325a;
|
||||
--hero-glow: #f4aec8;
|
||||
--bg-radial: #fde3ec;
|
||||
--bg-bottom: #f8dde8;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #fff7fa;
|
||||
--chip-bg: #fdeef3;
|
||||
--pill-bg: #fbd6e5;
|
||||
--pill-ink: #8a2444;
|
||||
--input-bg: #fffcfd;
|
||||
--radius-card: 22px;
|
||||
--radius-tile: 18px;
|
||||
--radius-panel: 32px;
|
||||
--radius-note: 16px;
|
||||
--radius-input: 12px;
|
||||
--radius-chip: 10px;
|
||||
--shadow: 0 2px 10px rgba(184, 50, 90, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 6px 28px rgba(184, 50, 90, 0.14), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--display: 'Georgia', 'Book Antiqua', 'Palatino', serif;
|
||||
}
|
||||
|
||||
:root[data-theme='slate'] {
|
||||
--bg: #f0f2f6;
|
||||
--ink: #181d28;
|
||||
--ink-muted: #49556e;
|
||||
--line: #cdd3e0;
|
||||
--accent: #3455e0;
|
||||
--hero-glow: #b5c1f5;
|
||||
--bg-radial: #e2e7f4;
|
||||
--bg-bottom: #e4e8f0;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f5f7fb;
|
||||
--chip-bg: #edf0f8;
|
||||
--pill-bg: #dce2f8;
|
||||
--pill-ink: #1e38be;
|
||||
--input-bg: #f9fafc;
|
||||
--radius-card: 8px;
|
||||
--radius-tile: 6px;
|
||||
--radius-panel: 12px;
|
||||
--radius-note: 8px;
|
||||
--radius-input: 6px;
|
||||
--radius-chip: 4px;
|
||||
--shadow: 0 1px 6px rgba(0, 0, 0, 0.08), 0 3px 10px rgba(52, 85, 224, 0.06);
|
||||
--shadow-elevated: 0 4px 20px rgba(0, 0, 0, 0.12), 0 8px 20px rgba(52, 85, 224, 0.08);
|
||||
--display: 'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme='aurora'] {
|
||||
--bg: #0c1220;
|
||||
--ink: #e5edf8;
|
||||
--ink-muted: #8096bb;
|
||||
--line: #263050;
|
||||
--accent: #00c9a7;
|
||||
--hero-glow: #0a4040;
|
||||
--bg-radial: #121d38;
|
||||
--bg-bottom: #090f1c;
|
||||
--surface: #131e34;
|
||||
--surface-soft: #192438;
|
||||
--chip-bg: #1e2e50;
|
||||
--pill-bg: #0e3040;
|
||||
--pill-ink: #7ef0d8;
|
||||
--input-bg: #0c1625;
|
||||
--shadow: 0 2px 12px rgba(0, 0, 0, 0.45), 0 0 20px rgba(0, 201, 167, 0.06);
|
||||
--shadow-elevated: 0 6px 28px rgba(0, 0, 0, 0.6), 0 0 40px rgba(0, 201, 167, 0.12);
|
||||
--display: 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
:root[data-theme='amethyst'] {
|
||||
--bg: #f2eff9;
|
||||
--ink: #1c1435;
|
||||
--ink-muted: #5e4e90;
|
||||
--line: #d4c8f0;
|
||||
--accent: #7c3aed;
|
||||
--hero-glow: #c4b0e8;
|
||||
--bg-radial: #e8e1f8;
|
||||
--bg-bottom: #e2d9f5;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8f6fc;
|
||||
--chip-bg: #f0ecfa;
|
||||
--pill-bg: #e6dbf8;
|
||||
--pill-ink: #5a28b5;
|
||||
--input-bg: #fbfafe;
|
||||
--radius-card: 24px;
|
||||
--radius-tile: 18px;
|
||||
--radius-panel: 32px;
|
||||
--radius-note: 18px;
|
||||
--radius-input: 14px;
|
||||
--radius-chip: 10px;
|
||||
--shadow: 0 2px 10px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-elevated: 0 6px 28px rgba(124, 58, 237, 0.16), 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--display: 'Georgia', 'Book Antiqua', 'Palatino', serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 0%, var(--bg-radial) 0%, rgba(255, 245, 222, 0) 38%),
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-bottom) 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||