Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e815d7702e | |||
| db942fd734 | |||
| 67b7d1024b | |||
| e6080349af | |||
| 87967bf149 | |||
| 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,7 @@ dist-ssr
|
|||||||
# the image default and is preserved here for the Docker build context only.
|
# 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.
|
# Uncomment the line below if you do NOT want to track live data in git.
|
||||||
# data/admin-content.json
|
# data/admin-content.json
|
||||||
|
data/backups/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
|
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: 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,65 @@ docker compose pull
|
|||||||
docker compose up -d
|
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.
|
||||||
|
|
||||||
The app will be available at `http://localhost:4173`.
|
The app will be available at `http://localhost:4173`.
|
||||||
|
|
||||||
|
## Optional local LLM (Ollama) for grounded rewrites
|
||||||
|
|
||||||
|
You can keep deterministic retrieval as the source-of-truth and optionally rewrite responses with a local model.
|
||||||
|
|
||||||
|
1. Install and run Ollama on your host.
|
||||||
|
2. Pull a small model suited to older hardware, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama pull qwen2.5:3b-instruct
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the API with these environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CHATBOT_LLM_ENABLED=true
|
||||||
|
CHATBOT_LLM_BASE_URL=http://127.0.0.1:11434
|
||||||
|
CHATBOT_LLM_MODEL=qwen2.5:3b-instruct
|
||||||
|
CHATBOT_LLM_TIMEOUT_MS=25000
|
||||||
|
CHATBOT_LLM_NUM_CTX=2048
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Call the rewrite endpoint from your existing chat flow:
|
||||||
|
|
||||||
|
`POST /api/chatbot-grounded-rewrite`
|
||||||
|
|
||||||
|
Request payload shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"question": "Who was Titus?",
|
||||||
|
"draftAnswer": "Deterministic answer produced by current retrieval/synthesis.",
|
||||||
|
"sources": ["Episode 2 - Introduction to Titus"],
|
||||||
|
"contextChunks": [
|
||||||
|
{
|
||||||
|
"title": "Episode 2 - Introduction to Titus",
|
||||||
|
"sourceLabel": "Episode 2",
|
||||||
|
"content": "Titus was a Gentile..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the endpoint fails or is disabled, keep your deterministic answer and existing fallback behavior.
|
||||||
|
|
||||||
Persistent admin saves:
|
Persistent admin saves:
|
||||||
|
|
||||||
- Admin updates are written to `data/admin-content.json`.
|
- Admin updates are written to `data/admin-content.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/`.
|
||||||
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
|
- `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
|
## Where to edit content
|
||||||
|
|
||||||
@@ -80,6 +132,12 @@ Persistent admin saves:
|
|||||||
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
|
- 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.
|
- Click **Save project** to apply updates instantly.
|
||||||
- Saved edits are written to `data/admin-content.json` through the API server.
|
- Saved edits are written to `data/admin-content.json` through the API server.
|
||||||
|
- 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.
|
||||||
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
|
- 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 **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`.
|
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
|
||||||
|
|||||||
@@ -1,400 +1,21 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
"siteContent": {
|
||||||
"theme": "sand",
|
"eyebrow": "A Journey Through Scripture",
|
||||||
"homeEyebrow": "Siteforge",
|
"heroTagline": "Exploring God's Word one verse at a time",
|
||||||
"homeTitle": "Sites and Apps I Build",
|
"aboutShowHeading": "Depth. Clarity. Application.",
|
||||||
"homeIntro": "Live links generated from your domain list. Use the Admin panel to update cards and About pages without editing code.",
|
"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.",
|
||||||
"projectEyebrow": "Project Details",
|
"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.",
|
||||||
"quickTipsTitle": "Quick edit tips",
|
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He created Verse by Verse to share the joy of deep Scripture study in a format anyone can follow along with — no seminary required.",
|
||||||
"quickTipsBody": "Open /admin to update card and featured page content. Changes are saved in your project data file via the API server.",
|
"seriesLabel": "Now Playing",
|
||||||
"downloadsTitle": "Downloads",
|
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||||
"downloadsIntro": "Add shared files, installers, or documents here. Seafile share links work well for this section.",
|
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||||
"downloads": [
|
"studyGuideTitle": "Companion Study Guide",
|
||||||
{
|
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||||
"title": "Shared File",
|
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||||
"url": "https://seafile.necloud.us",
|
"shareHeading": "Know someone who'd love this?",
|
||||||
"description": "Replace this with a direct Seafile share link or another download URL."
|
"shareP": "Scan the QR code or share the link to introduce a friend to Verse by Verse with Nate.",
|
||||||
}
|
"customLinks": [],
|
||||||
],
|
"customBlocks": []
|
||||||
"showSidebar": true,
|
|
||||||
"showWeatherWidget": true,
|
|
||||||
"showMusicBar": true,
|
|
||||||
"homeSections": [
|
|
||||||
{
|
|
||||||
"id": "summary",
|
|
||||||
"placement": "main"
|
|
||||||
},
|
},
|
||||||
{
|
"updatedAt": "2026-04-09T00:00:00.000Z"
|
||||||
"id": "projects",
|
|
||||||
"placement": "main"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "downloads",
|
|
||||||
"placement": "sidebar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "quickTips",
|
|
||||||
"placement": "sidebar"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"updatedAt": "2026-03-19T19:19:04.291Z"
|
|
||||||
}
|
}
|
||||||
@@ -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 @@
|
|||||||
|
[]
|
||||||
@@ -7,6 +7,8 @@ services:
|
|||||||
- "4173:4173"
|
- "4173:4173"
|
||||||
environment:
|
environment:
|
||||||
- PORT=4173
|
- PORT=4173
|
||||||
|
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||||
|
- RESEND_API_KEY=`set your Resend API key here`
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
DATA_FILE=/app/data/admin-content.json
|
DATA_DIR=/app/data
|
||||||
SEED_FILE=/app/data-seed/admin-content.json
|
SEED_DIR=/app/data-seed
|
||||||
|
|
||||||
# Ensure the data directory exists (in case the volume was not mounted)
|
# 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.
|
# This runs on first boot or a fresh volume, but never overwrites existing data.
|
||||||
if [ ! -f "$DATA_FILE" ] && [ -f "$SEED_FILE" ]; then
|
if [ -d "$SEED_DIR" ]; then
|
||||||
echo "[siteforge] No admin-content.json found. Seeding from image defaults..."
|
for seed_file in "$SEED_DIR"/*.json; do
|
||||||
cp "$SEED_FILE" "$DATA_FILE"
|
[ -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
|
fi
|
||||||
|
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -4,7 +4,24 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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://necloud.us/images/banner.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:secure_url" content="https://necloud.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://necloud.us" />
|
||||||
|
<meta property="og:site_name" content="Verse by Verse with Nate" />
|
||||||
|
<link rel="canonical" href="https://necloud.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=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Barlow+Condensed:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"resend": "^6.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -863,6 +864,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -2239,6 +2246,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -4309,6 +4322,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -4582,6 +4601,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -4866,6 +4906,16 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -4961,6 +5011,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -5246,6 +5306,19 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"api": "node server.js",
|
"api": "node --env-file=.env server.js",
|
||||||
"dev:full": "concurrently \"npm:api\" \"npm:dev\"",
|
"dev:full": "concurrently \"npm:api\" \"npm:dev\"",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "node server.js",
|
"start": "node --env-file=.env server.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1",
|
||||||
|
"resend": "^6.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@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: 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,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,194 @@
|
|||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const ROOT = '/Users/nate.emmert/Documents/github/Siteforge'
|
||||||
|
const DOCS_DIR = path.join(ROOT, 'Verse by Verse with Nate Complete Series')
|
||||||
|
const CHATBOT_FILE = path.join(ROOT, 'data', 'chatbot-content.json')
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'your', 'you', 'are', 'but', 'not', 'have',
|
||||||
|
'has', 'was', 'were', 'his', 'her', 'our', 'their', 'into', 'about', 'what', 'when', 'where',
|
||||||
|
'which', 'will', 'just', 'they', 'them', 'then', 'than', 'how', 'why', 'can', 'all', 'through',
|
||||||
|
])
|
||||||
|
|
||||||
|
function parseEpisodeNumber(filePath) {
|
||||||
|
const match = path.basename(filePath).match(/Episode(\d+)/i)
|
||||||
|
return match ? Number(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantRank(filePath) {
|
||||||
|
const name = path.basename(filePath).toLowerCase()
|
||||||
|
let score = 0
|
||||||
|
if (name.includes('expanded')) score += 30
|
||||||
|
if (name.includes('updated')) score += 20
|
||||||
|
if (!name.includes('expanded') && !name.includes('updated')) score += 10
|
||||||
|
if (filePath.includes(`${path.sep}Done${path.sep}Old${path.sep}`)) score -= 25
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDocxFiles(dir) {
|
||||||
|
const out = []
|
||||||
|
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dir, item.name)
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
out.push(...await collectDocxFiles(fullPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!item.isFile()) continue
|
||||||
|
if (!item.name.toLowerCase().endsWith('.docx')) continue
|
||||||
|
if (item.name.startsWith('~$')) continue
|
||||||
|
out.push(fullPath)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestPerEpisode(docxFiles) {
|
||||||
|
const byEpisode = new Map()
|
||||||
|
|
||||||
|
for (const filePath of docxFiles) {
|
||||||
|
const episode = parseEpisodeNumber(filePath)
|
||||||
|
if (!episode) continue
|
||||||
|
|
||||||
|
const current = byEpisode.get(episode)
|
||||||
|
const next = {
|
||||||
|
filePath,
|
||||||
|
episode,
|
||||||
|
rank: getVariantRank(filePath),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current || next.rank > current.rank) {
|
||||||
|
byEpisode.set(episode, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byEpisode.values()].sort((a, b) => a.episode - b.episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDocText(filePath) {
|
||||||
|
const output = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], { encoding: 'utf8' })
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContent(text) {
|
||||||
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(line => line.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const filtered = lines.filter(line => {
|
||||||
|
const upper = line.toUpperCase()
|
||||||
|
if (upper === 'VERSE BY VERSE WITH NATE') return false
|
||||||
|
if (upper === 'A JOURNEY THROUGH SCRIPTURE') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.join(' ').replace(/\s{2,}/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeywords(title, content, existingKeywords = []) {
|
||||||
|
const tokens = `${title} ${content.slice(0, 1600)}`
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s:-]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(token => token.length >= 3 && !STOP_WORDS.has(token))
|
||||||
|
|
||||||
|
const counts = new Map()
|
||||||
|
for (const token of tokens) {
|
||||||
|
counts.set(token, (counts.get(token) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...counts.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(([token]) => token)
|
||||||
|
|
||||||
|
return [...new Set([...(existingKeywords ?? []), ...top])].slice(0, 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEpisodeFromTitle(title = '') {
|
||||||
|
const match = title.match(/Episode\s+(\d+)/i)
|
||||||
|
return match ? Number(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryTitleFallback(episodeNumber, rawText, existingTitle) {
|
||||||
|
if (existingTitle && existingTitle.trim()) return existingTitle
|
||||||
|
|
||||||
|
const lineMatch = rawText.match(new RegExp(`EPISODE\\s+${episodeNumber}\\s*[—-]\\s*([^\\n]+)`, 'i'))
|
||||||
|
if (lineMatch) {
|
||||||
|
return `Episode ${episodeNumber} — ${lineMatch[1].trim()}`
|
||||||
|
}
|
||||||
|
return `Episode ${episodeNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const raw = await fs.readFile(CHATBOT_FILE, 'utf8')
|
||||||
|
const entries = JSON.parse(raw)
|
||||||
|
|
||||||
|
const docxFiles = await collectDocxFiles(DOCS_DIR)
|
||||||
|
const selected = pickBestPerEpisode(docxFiles)
|
||||||
|
|
||||||
|
const existingByEpisode = new Map()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const episode = getEpisodeFromTitle(entry.title)
|
||||||
|
if (episode) existingByEpisode.set(episode, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let updated = 0
|
||||||
|
let added = 0
|
||||||
|
|
||||||
|
for (const item of selected) {
|
||||||
|
const rawText = extractDocText(item.filePath)
|
||||||
|
const content = normalizeContent(rawText)
|
||||||
|
if (!content) continue
|
||||||
|
|
||||||
|
const existing = existingByEpisode.get(item.episode)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.type = 'episode'
|
||||||
|
existing.title = getEntryTitleFallback(item.episode, rawText, existing.title)
|
||||||
|
existing.content = content
|
||||||
|
existing.keywords = buildKeywords(existing.title, content, existing.keywords)
|
||||||
|
existing.updatedAt = now
|
||||||
|
updated += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
type: 'episode',
|
||||||
|
title: getEntryTitleFallback(item.episode, rawText, ''),
|
||||||
|
content,
|
||||||
|
keywords: buildKeywords(`Episode ${item.episode}`, content, []),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const aEp = getEpisodeFromTitle(a.title)
|
||||||
|
const bEp = getEpisodeFromTitle(b.title)
|
||||||
|
if (aEp && bEp) return aEp - bEp
|
||||||
|
if (aEp && !bEp) return 1
|
||||||
|
if (!aEp && bEp) return -1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.writeFile(CHATBOT_FILE, `${JSON.stringify(entries, null, 2)}\n`)
|
||||||
|
|
||||||
|
console.log(`Episodes selected from docs: ${selected.length}`)
|
||||||
|
console.log(`Updated entries: ${updated}`)
|
||||||
|
console.log(`Added entries: ${added}`)
|
||||||
|
for (const item of selected) {
|
||||||
|
console.log(`- Episode ${item.episode}: ${path.relative(ROOT, item.filePath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(error => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -29,243 +29,29 @@ export type Project = {
|
|||||||
|
|
||||||
export const projects: Project[] = [
|
export const projects: Project[] = [
|
||||||
{
|
{
|
||||||
slug: 'weather',
|
slug: 'verse-by-verse',
|
||||||
title: 'NeCloud Weather',
|
title: 'Verse by Verse with Nate',
|
||||||
domain: 'weather.necloud.us',
|
domain: 'spotify.com',
|
||||||
url: 'https://weather.necloud.us',
|
url: 'https://creators.spotify.com/pod/profile/nmemmert/',
|
||||||
readmeUrl: 'https://raw.githubusercontent.com/nmemmert/weather/main/README.md',
|
category: 'Podcast',
|
||||||
category: 'Weather',
|
|
||||||
access: 'Public',
|
access: 'Public',
|
||||||
ownership: 'built',
|
ownership: 'hosted',
|
||||||
status: 'Live',
|
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:
|
details:
|
||||||
'The homepage highlights Open-Meteo and RainViewer data, current conditions, high/low, daylight windows, and quick bring-along suggestions.',
|
'Weekly episodes explore Scripture one verse at a time, mixing theological insight, devotional reflection, and real-life application.',
|
||||||
features: ['Hourly forecast', 'Radar view', 'Wind and atmosphere panel'],
|
features: ['Verse-by-verse teaching', 'Weekly podcast episodes', 'Practical reflection'],
|
||||||
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',
|
scanSource: 'manual',
|
||||||
featured: {
|
featured: {
|
||||||
headline: 'Skywatch project overview placeholder.',
|
headline: 'Podcast teaching shaped around verse-by-verse discovery.',
|
||||||
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:
|
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:
|
solution:
|
||||||
'Spelling Hub keeps onboarding simple and centers the product on practical, repeatable learning loops.',
|
'Verse by Verse with Nate brings Scripture study and podcast conversation together in a format that is easy to follow and apply.',
|
||||||
stack: ['React', 'TypeScript', 'Auth-backed workflow'],
|
stack: ['Podcast hosting', 'Spotify', 'Verse-by-verse teaching'],
|
||||||
highlights: ['Login/register flow', 'Practice-first design', 'Simple navigation'],
|
highlights: ['Episode guides', 'Scripture focus', 'Practical application'],
|
||||||
nextSteps: ['Add custom word lists', 'Teacher/student modes', 'Progress analytics dashboard'],
|
nextSteps: ['Add episode notes', 'Add guest interviews', 'Link transcripts and study resources'],
|
||||||
updateNote: 'Edit this content in src/data/projects.ts under the spellinghub project.',
|
updateNote: 'Edit this content in src/data/projects.ts under the Verse by Verse podcast 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.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,218 +1,33 @@
|
|||||||
:root {
|
*, *::before, *::after {
|
||||||
--bg: #f6f0e8;
|
box-sizing: border-box;
|
||||||
--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);
|
|
||||||
|
|
||||||
font-family: var(--body);
|
:root {
|
||||||
line-height: 1.45;
|
font-family: 'Barlow Condensed', 'Trebuchet MS', sans-serif;
|
||||||
color: var(--ink);
|
line-height: 1.5;
|
||||||
|
color: #f0e6d0;
|
||||||
|
background: #0a0a0a;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background:
|
padding: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
img {
|
||||||
min-height: 100vh;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||