Merge beta: TOTP 2FA, admin asset manager, resource page redesign, rate limiting, and security hardening
This commit is contained in:
@@ -21,6 +21,12 @@ data/backups/
|
||||
data/hit-stats.json
|
||||
data/visitor-stats.json
|
||||
data/contact-submissions.json
|
||||
data/totp-secret.json
|
||||
data/uploads-meta.json
|
||||
|
||||
# Local environment secrets — never commit
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -17,13 +17,17 @@
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
"seriesImageUrl": "/images/titus-cover.png",
|
||||
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png",
|
||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideDownloadUrl": "",
|
||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||
"shareHeading": "Help one more person hear the Word this week.",
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
@@ -97,5 +101,5 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-04-27T20:24:22.589Z"
|
||||
"updatedAt": "2026-05-04T17:42:41.930Z"
|
||||
}
|
||||
@@ -17,13 +17,17 @@
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
"seriesImageUrl": "/images/titus-cover.png",
|
||||
"seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png",
|
||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideDownloadUrl": "",
|
||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||
"shareHeading": "Help one more person hear the Word this week.",
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
@@ -97,5 +101,5 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-04-27T20:24:19.789Z"
|
||||
"updatedAt": "2026-05-04T17:42:41.949Z"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Generated
+316
-11
@@ -9,6 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -582,6 +585,74 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz",
|
||||
"integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/hotp": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz",
|
||||
"integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-base32-scure": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz",
|
||||
"integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@scure/base": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto-noble": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz",
|
||||
"integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@otplib/core": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/totp": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz",
|
||||
"integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/hotp": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/uri": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz",
|
||||
"integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/runtime": {
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
|
||||
@@ -864,6 +935,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
@@ -1355,7 +1435,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1365,7 +1444,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -1531,6 +1609,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001780",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
||||
@@ -1638,7 +1725,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -1651,7 +1737,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
@@ -1801,6 +1886,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||
@@ -1862,6 +1956,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1893,7 +1993,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
@@ -2210,6 +2309,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
|
||||
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
@@ -2398,7 +2515,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -2665,6 +2781,15 @@
|
||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -2722,7 +2847,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4193,6 +4317,20 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "13.4.0",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz",
|
||||
"integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "13.4.0",
|
||||
"@otplib/hotp": "13.4.0",
|
||||
"@otplib/plugin-base32-scure": "13.4.0",
|
||||
"@otplib/plugin-crypto-noble": "13.4.0",
|
||||
"@otplib/totp": "13.4.0",
|
||||
"@otplib/uri": "13.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -4225,6 +4363,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -4276,7 +4423,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4322,6 +4468,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||
@@ -4400,6 +4555,141 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
@@ -4595,12 +4885,17 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz",
|
||||
@@ -4766,6 +5061,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -4929,7 +5230,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -4958,7 +5258,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -5451,6 +5750,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
+4
-2
@@ -10,11 +10,13 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node --env-file=.env server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"otplib": "^13.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseCookies } from './helpers.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const TOTP_SECRET_FILE = path.join(__dirname, '..', 'data', 'totp-secret.json')
|
||||
|
||||
// Pending sessions: password verified, waiting for TOTP code
|
||||
// Map<pendingToken, { expiresAt }>
|
||||
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||
const totpPendingSessions = new Map()
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||
const adminSessions = new Map()
|
||||
|
||||
function cookieFlags() {
|
||||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
}
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(String(value)).digest('hex')
|
||||
}
|
||||
|
||||
export function isAdminPasswordConfigured() {
|
||||
return Boolean(ADMIN_PASSWORD)
|
||||
}
|
||||
|
||||
export function validateAdminPasswordSetup() {
|
||||
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('ADMIN_PASSWORD is required in production.')
|
||||
}
|
||||
if (!isAdminPasswordConfigured()) {
|
||||
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdminPasswordValid(password) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
const a = Buffer.from(sha256(password), 'utf8')
|
||||
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
|
||||
|
||||
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
function base32Decode(str) {
|
||||
const s = str.toUpperCase().replace(/=+$/, '')
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const output = []
|
||||
for (const char of s) {
|
||||
const idx = BASE32_CHARS.indexOf(char)
|
||||
if (idx === -1) continue
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return Buffer.from(output)
|
||||
}
|
||||
|
||||
function base32Encode(buf) {
|
||||
let bits = 0
|
||||
let value = 0
|
||||
let output = ''
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte
|
||||
bits += 8
|
||||
while (bits >= 5) {
|
||||
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
|
||||
return output
|
||||
}
|
||||
|
||||
function totpToken(secret, counter) {
|
||||
const key = base32Decode(secret)
|
||||
const msg = Buffer.alloc(8)
|
||||
// Write 64-bit big-endian counter
|
||||
const hi = Math.floor(counter / 0x100000000)
|
||||
const lo = counter >>> 0
|
||||
msg.writeUInt32BE(hi, 0)
|
||||
msg.writeUInt32BE(lo, 4)
|
||||
const hmac = createHmac('sha1', key).update(msg).digest()
|
||||
const offset = hmac[hmac.length - 1] & 0x0f
|
||||
const code = ((hmac[offset] & 0x7f) << 24)
|
||||
| (hmac[offset + 1] << 16)
|
||||
| (hmac[offset + 2] << 8)
|
||||
| hmac[offset + 3]
|
||||
return String(code % 1000000).padStart(6, '0')
|
||||
}
|
||||
|
||||
export function generateTotpSecret() {
|
||||
const buf = Buffer.allocUnsafe(20)
|
||||
randomFillSync(buf)
|
||||
return base32Encode(buf)
|
||||
}
|
||||
|
||||
export async function loadTotpState() {
|
||||
try {
|
||||
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTotpState(state) {
|
||||
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
export async function isTotpEnabled() {
|
||||
const state = await loadTotpState()
|
||||
return Boolean(state?.secret && state?.verified)
|
||||
}
|
||||
|
||||
function randomBytesForRecovery(n) {
|
||||
const buf = Buffer.allocUnsafe(n)
|
||||
randomFillSync(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
export function getTotpUri(secret, label = 'Siteforge Admin') {
|
||||
const issuer = 'Siteforge'
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret, code) {
|
||||
try {
|
||||
const token = String(code).replace(/\s/g, '')
|
||||
const step = Math.floor(Date.now() / 1000 / 30)
|
||||
// Accept current step and one step either side (±30 seconds clock skew)
|
||||
for (const offset of [-1, 0, 1]) {
|
||||
if (totpToken(secret, step + offset) === token) return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery Codes ──────────────────────────────────────────────────────────
|
||||
|
||||
const RECOVERY_CODE_COUNT = 8
|
||||
|
||||
function generateRecoveryCode() {
|
||||
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const randBytes = randomBytesForRecovery(12)
|
||||
let code = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (i > 0 && i % 4 === 0) code += '-'
|
||||
code += chars[randBytes[i] % chars.length]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes() {
|
||||
const codes = []
|
||||
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||
codes.push(generateRecoveryCode())
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code) {
|
||||
return sha256(code.replace(/-/g, '').toUpperCase())
|
||||
}
|
||||
|
||||
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
|
||||
export function consumeRecoveryCode(state, inputCode) {
|
||||
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
|
||||
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
|
||||
const inputHash = sha256(normalized)
|
||||
const idx = state.hashedRecoveryCodes.findIndex(h => {
|
||||
const a = Buffer.from(h, 'utf8')
|
||||
const b = Buffer.from(inputHash, 'utf8')
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
})
|
||||
if (idx === -1) return false
|
||||
state.hashedRecoveryCodes.splice(idx, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
|
||||
|
||||
export function createPendingSession() {
|
||||
const token = randomUUID()
|
||||
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
export function consumePendingSession(token) {
|
||||
if (!token) return false
|
||||
const entry = totpPendingSessions.get(token)
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
totpPendingSessions.delete(token)
|
||||
return false
|
||||
}
|
||||
totpPendingSessions.delete(token)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Admin Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
export function createAdminSession() {
|
||||
const token = randomUUID()
|
||||
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteAdminSession(token) {
|
||||
if (token) {
|
||||
adminSessions.delete(token)
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAdminSession(req) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||
if (!sessionToken) return false
|
||||
|
||||
const expiresAt = adminSessions.get(sessionToken)
|
||||
if (!expiresAt || expiresAt <= Date.now()) {
|
||||
adminSessions.delete(sessionToken)
|
||||
return false
|
||||
}
|
||||
|
||||
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
export function setAdminSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function requireAdminAuth(req, res, next) {
|
||||
if (!isValidAdminSession(req)) {
|
||||
res.status(401).json({ message: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export const DEFAULT_REDIRECT_RULES = [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_SEO = {
|
||||
title: 'Verse by Verse with Nate',
|
||||
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
}
|
||||
|
||||
export const DEFAULT_LEGAL = {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'Content on this site is for informational and ministry purposes.',
|
||||
'External links are provided for convenience and are subject to third-party policies.',
|
||||
'By using this site, you agree to lawful use and respectful communication.',
|
||||
],
|
||||
}
|
||||
|
||||
export const DEFAULT_PODCAST_FEATURED_LINKS = []
|
||||
export const DEFAULT_PUBLISH_STATE = {
|
||||
draftUpdatedAt: null,
|
||||
publishedAt: null,
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function splitName(fullName) {
|
||||
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
|
||||
return {
|
||||
firstName: parts[0] ?? '',
|
||||
lastName: parts.slice(1).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('/')) return trimmed
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeRedirectPath(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
const normalized = withSlash.replace(/\/+/g, '/')
|
||||
if (normalized === '/') return ''
|
||||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeSitemapPath(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed === '/') return '/'
|
||||
return normalizeRedirectPath(trimmed)
|
||||
}
|
||||
|
||||
export function sanitizeRedirectRules(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const item of source) {
|
||||
const pathValue = normalizeRedirectPath(item?.path)
|
||||
const target = sanitizeUrl(item?.target)
|
||||
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
||||
if (!pathValue || !target) continue
|
||||
if (seen.has(pathValue)) continue
|
||||
seen.add(pathValue)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
path: pathValue,
|
||||
target,
|
||||
statusCode,
|
||||
})
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
||||
}
|
||||
|
||||
export function sanitizeFeaturedLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => {
|
||||
const discussionQuestions = Array.isArray(item.discussionQuestions)
|
||||
? item.discussionQuestions
|
||||
.filter(question => typeof question === 'string')
|
||||
.map(question => question.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 30)
|
||||
: []
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
embedUrl: sanitizeUrl(item.embedUrl),
|
||||
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
|
||||
discussionQuestions,
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
item =>
|
||||
item.title ||
|
||||
item.summary ||
|
||||
item.url ||
|
||||
item.embedUrl ||
|
||||
item.showNotes ||
|
||||
item.discussionQuestions.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeCustomLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => {
|
||||
const placement =
|
||||
item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources'
|
||||
? item.placement
|
||||
: 'footer'
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
imageUrl: sanitizeUrl(item.imageUrl),
|
||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '',
|
||||
placement,
|
||||
}
|
||||
})
|
||||
.filter(item => item.label && item.url)
|
||||
}
|
||||
|
||||
function sanitizeCustomBlocks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||||
}))
|
||||
.filter(item => item.heading || item.body)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeriesResourceLinks(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '',
|
||||
url: sanitizeUrl(item.url),
|
||||
}))
|
||||
.filter(item => item.label && item.url)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeriesNotes(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||||
}))
|
||||
.filter(item => item.heading || item.body)
|
||||
}
|
||||
|
||||
function sanitizeArchivedSeries(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map(item => ({
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
|
||||
imageUrl: sanitizeUrl(item.imageUrl),
|
||||
listenUrl: sanitizeUrl(item.listenUrl),
|
||||
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
|
||||
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
|
||||
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
|
||||
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
|
||||
notes: sanitizeArchivedSeriesNotes(item.notes),
|
||||
}))
|
||||
.filter(
|
||||
item =>
|
||||
item.title ||
|
||||
item.description ||
|
||||
item.resourceLinks.length > 0 ||
|
||||
item.notes.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeSiteContent(siteContent) {
|
||||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
|
||||
|
||||
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
|
||||
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
|
||||
|
||||
return {
|
||||
...siteContent,
|
||||
customLinks: sanitizeCustomLinks(siteContent.customLinks),
|
||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||
seo: {
|
||||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
||||
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
||||
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
|
||||
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
|
||||
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
|
||||
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
|
||||
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
|
||||
sitemapPaths: Array.isArray(seo.sitemapPaths)
|
||||
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
|
||||
: [...DEFAULT_SEO.sitemapPaths],
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
|
||||
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
|
||||
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||
: [...DEFAULT_LEGAL.privacyBody],
|
||||
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
|
||||
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
|
||||
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||
: [...DEFAULT_LEGAL.termsBody],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
|
||||
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
|
||||
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
|
||||
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
|
||||
const value = maybeRelativePath.trim()
|
||||
if (/^https?:\/\//i.test(value)) return value
|
||||
if (value.startsWith('/')) return `${root}${value}`
|
||||
return `${root}/${value}`
|
||||
}
|
||||
|
||||
export function injectSeoIntoHtml(html, siteContent) {
|
||||
const seo = siteContent?.seo ?? DEFAULT_SEO
|
||||
const title = seo.title || DEFAULT_SEO.title
|
||||
const description = seo.description || DEFAULT_SEO.description
|
||||
const ogTitle = seo.ogTitle || title
|
||||
const ogDescription = seo.ogDescription || description
|
||||
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
|
||||
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
|
||||
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
|
||||
|
||||
return html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
|
||||
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
|
||||
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
|
||||
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
|
||||
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
|
||||
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
|
||||
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
|
||||
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
|
||||
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
|
||||
}
|
||||
|
||||
export function normalizeAssetBaseName(name) {
|
||||
if (typeof name !== 'string') return `upload-${Date.now()}`
|
||||
const cleaned = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
return cleaned || `upload-${Date.now()}`
|
||||
}
|
||||
|
||||
export function inferImageExtensionFromDataUrl(dataUrl) {
|
||||
if (typeof dataUrl !== 'string') return null
|
||||
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
|
||||
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
|
||||
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
|
||||
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
|
||||
if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf'
|
||||
if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc'
|
||||
if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx'
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeIp(rawIp) {
|
||||
if (!rawIp) return 'unknown'
|
||||
|
||||
let ip = String(rawIp).trim()
|
||||
|
||||
if (ip.includes(',')) {
|
||||
ip = ip.split(',')[0].trim()
|
||||
}
|
||||
|
||||
if (ip.startsWith('::ffff:')) {
|
||||
ip = ip.slice(7)
|
||||
}
|
||||
|
||||
if (ip === '::1') {
|
||||
ip = '127.0.0.1'
|
||||
}
|
||||
|
||||
return ip || 'unknown'
|
||||
}
|
||||
|
||||
export function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for']
|
||||
if (forwarded) {
|
||||
return normalizeIp(forwarded)
|
||||
}
|
||||
return normalizeIp(req.ip)
|
||||
}
|
||||
|
||||
export function parseCookies(cookieHeader) {
|
||||
if (!cookieHeader) return {}
|
||||
|
||||
return cookieHeader
|
||||
.split(';')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((acc, part) => {
|
||||
const idx = part.indexOf('=')
|
||||
if (idx === -1) return acc
|
||||
const key = part.slice(0, idx).trim()
|
||||
const value = part.slice(idx + 1).trim()
|
||||
try {
|
||||
acc[key] = decodeURIComponent(value)
|
||||
} catch {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function hasVisitorConsent(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
return cookies['vbn_analytics_consent'] === 'yes'
|
||||
}
|
||||
|
||||
export function setConsentCookie(res, consent) {
|
||||
const value = consent ? 'yes' : 'no'
|
||||
const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`)
|
||||
}
|
||||
|
||||
export function isPrivateOrLocalIp(ip) {
|
||||
return (
|
||||
ip === '127.0.0.1' ||
|
||||
ip === 'localhost' ||
|
||||
ip.startsWith('10.') ||
|
||||
ip.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
|
||||
ip.startsWith('fc') ||
|
||||
ip.startsWith('fd') ||
|
||||
ip.startsWith('fe80:') ||
|
||||
ip === 'unknown'
|
||||
)
|
||||
}
|
||||
+667
-147
File diff suppressed because it is too large
Load Diff
+356
-423
@@ -919,93 +919,6 @@
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Contact ── */
|
||||
.section-chatbot-feature {
|
||||
padding: 5rem 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(201, 168, 76, 0.14), transparent 38%),
|
||||
linear-gradient(180deg, rgba(22, 18, 10, 0.96), rgba(11, 11, 11, 0.98));
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.18);
|
||||
}
|
||||
|
||||
.chatbot-feature-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(300px, 0.85fr);
|
||||
gap: 2rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.chatbot-feature-copy,
|
||||
.chatbot-feature-card {
|
||||
background: rgba(18, 18, 18, 0.84);
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 18px;
|
||||
padding: 1.7rem;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.chatbot-feature-copy .section-heading {
|
||||
text-align: left;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chatbot-feature-lead,
|
||||
.chatbot-feature-sub,
|
||||
.chatbot-feature-kicker {
|
||||
font-family: var(--brand-font-body);
|
||||
margin: 0;
|
||||
color: #d9c9a0;
|
||||
}
|
||||
|
||||
.chatbot-feature-lead {
|
||||
font-size: clamp(1.2rem, 2vw, 1.55rem);
|
||||
line-height: 1.45;
|
||||
color: var(--brand-warm-white);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.chatbot-feature-sub {
|
||||
margin-top: 0.85rem;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.65;
|
||||
color: #ab9568;
|
||||
}
|
||||
|
||||
.chatbot-feature-actions {
|
||||
margin-top: 1.35rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.chatbot-feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chatbot-feature-kicker {
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-gold);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chatbot-feature-prompts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.chatbot-prompt-btn--feature {
|
||||
font-size: 0.86rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
text-align: left;
|
||||
color: #efd8a1;
|
||||
}
|
||||
|
||||
.section-contact {
|
||||
background: #090909;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||
@@ -1455,29 +1368,148 @@
|
||||
}
|
||||
|
||||
.resources-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-link {
|
||||
.resource-link {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 86px;
|
||||
padding: 1rem 1.2rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brand-gold);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--brand-warm-white);
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.4rem;
|
||||
transition: background 200ms, color 200ms;
|
||||
}
|
||||
background: #111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-radius: 18px;
|
||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.resource-link:hover {
|
||||
background: rgba(201, 168, 76, 0.1);
|
||||
color: #d4b87a;
|
||||
}
|
||||
.resource-link:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
border-color: rgba(201, 168, 76, 0.35);
|
||||
}
|
||||
|
||||
.resource-link--with-image {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.resource-download-card {
|
||||
background: #111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.resource-download-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resource-download-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-link-label {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.resource-link-action {
|
||||
color: var(--brand-gold);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-link-image {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-link-tags {
|
||||
margin-top: 0.15rem;
|
||||
color: rgba(201, 168, 76, 0.9);
|
||||
font-size: 0.88rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.section-download-library {
|
||||
background:
|
||||
radial-gradient(circle at top center, rgba(201, 168, 76, 0.1), transparent 26%),
|
||||
var(--brand-black);
|
||||
}
|
||||
|
||||
.download-library-head {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.download-library-copy {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: var(--brand-muted);
|
||||
margin: 0.85rem auto 0;
|
||||
}
|
||||
|
||||
.download-library-group {
|
||||
background: rgba(17, 17, 17, 0.82);
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.download-library-group + .download-library-group {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.download-library-group-head {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.download-library-group-head h3,
|
||||
.download-library-series-head h4 {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
font-size: clamp(1.25rem, 2vw, 1.7rem);
|
||||
}
|
||||
|
||||
.download-library-group-head p,
|
||||
.download-library-series-head p {
|
||||
margin: 0.45rem 0 0;
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.download-library-series-head {
|
||||
margin: 1.35rem 0 0.9rem;
|
||||
}
|
||||
|
||||
.download-library-series-head:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ── Custom content blocks ── */
|
||||
.section-custom-block {
|
||||
@@ -1540,6 +1572,17 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.guide-page-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(2rem, 3.6vw, 3rem);
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0.1rem 0 0.85rem;
|
||||
}
|
||||
|
||||
.guide-page-intro {
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.guide-text p {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 300;
|
||||
@@ -1783,7 +1826,8 @@
|
||||
}
|
||||
|
||||
.admin-form-wrap {
|
||||
max-width: 760px;
|
||||
max-width: 1400px;
|
||||
width: min(100%, 1400px);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 5rem;
|
||||
}
|
||||
@@ -1795,6 +1839,31 @@
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.admin-action-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-action-toolbar-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
color: var(--brand-muted);
|
||||
font-size: 0.95rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-actions--toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-top-tabs .admin-tab {
|
||||
flex: 1 1 180px;
|
||||
min-width: 180px;
|
||||
@@ -2111,6 +2180,28 @@
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-assets-table input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #090909;
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.admin-assets-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.resource-link-tags {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--brand-gold);
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.admin-restore-row {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
@@ -2303,11 +2394,16 @@
|
||||
}
|
||||
|
||||
.admin-form-section-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-form-section-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.admin-form-section .admin-section-header {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
@@ -2424,21 +2520,61 @@
|
||||
/* ── Admin: select input ── */
|
||||
.admin-field select {
|
||||
background: #111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
border-radius: 6px;
|
||||
color: var(--brand-warm-white);
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
padding: 0.65rem 0.85rem;
|
||||
padding: 0.65rem 1rem 0.65rem 0.85rem;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
transition: border-color 200ms;
|
||||
appearance: auto;
|
||||
transition: border-color 200ms, background-image 200ms;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(45deg, transparent 50%, rgba(255,255,255,0.9) 50%), linear-gradient(135deg, rgba(255,255,255,0.9) 50%, transparent 50%);
|
||||
background-position: calc(100% - 0.75rem) calc(50% - 0.15rem), calc(100% - 0.4rem) calc(50% - 0.15rem);
|
||||
background-size: 0.45rem 0.45rem, 0.45rem 0.45rem;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.admin-field select:focus {
|
||||
border-color: rgba(201, 168, 76, 0.65);
|
||||
border-color: rgba(201, 168, 76, 0.75);
|
||||
}
|
||||
|
||||
.admin-field-asset-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.16);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.admin-field-asset-picker label {
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.admin-field-asset-picker label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--brand-gold);
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-field-asset-picker select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: #111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.45);
|
||||
border-radius: 6px;
|
||||
color: var(--brand-warm-white);
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.admin-field-asset-picker select:focus {
|
||||
border-color: rgba(201, 168, 76, 0.75);
|
||||
}
|
||||
|
||||
/* ── Admin: section divider ── */
|
||||
@@ -2596,12 +2732,94 @@
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-admin-add {
|
||||
background: transparent;
|
||||
color: var(--brand-gold);
|
||||
.admin-collapsible-card {
|
||||
margin-bottom: 0.85rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||
border-radius: 10px;
|
||||
background: #0d0d0d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-collapsible-card[open] {
|
||||
border-color: rgba(201, 168, 76, 0.28);
|
||||
}
|
||||
|
||||
.admin-collapsible-card--group {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-collapsible-card--nested {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-collapsible-summary {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-collapsible-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-collapsible-summary strong {
|
||||
display: block;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.admin-collapsible-summary p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--brand-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.admin-collapsible-hint {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-gold);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-collapsible-body {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.admin-collapsible-body .admin-array-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 20;
|
||||
background: rgba(12, 12, 12, 0.96);
|
||||
color: var(--brand-gold);
|
||||
border: 1px solid rgba(201, 168, 76, 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 700;
|
||||
transition: transform 0.25s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
}
|
||||
|
||||
.btn-admin-add {
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
@@ -2637,6 +2855,27 @@
|
||||
border-color: rgba(224, 92, 92, 0.4);
|
||||
}
|
||||
|
||||
.btn-admin-apply {
|
||||
background: transparent;
|
||||
color: var(--brand-gold);
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
transition: background 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.btn-admin-apply:hover {
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
border-color: rgba(201, 168, 76, 0.65);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
@@ -2737,19 +2976,6 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chatbot-feature-inner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chatbot-feature-copy .section-heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chatbot-feature-actions,
|
||||
.chatbot-feature-prompts {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
max-width: 100%;
|
||||
padding: 1.45rem;
|
||||
@@ -2790,7 +3016,6 @@
|
||||
.section-series,
|
||||
.section-guide,
|
||||
.section-home-jump,
|
||||
.section-chatbot-feature,
|
||||
.section-contact {
|
||||
padding: 3.5rem 0;
|
||||
}
|
||||
@@ -3189,298 +3414,6 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════
|
||||
FLOATING CHATBOT
|
||||
══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* Bubble trigger */
|
||||
.chatbot-bubble {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 1000;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-gold);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.chatbot-bubble:hover {
|
||||
background: #e8a91a;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.chatbot-bubble--open {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.chatbot-panel {
|
||||
position: fixed;
|
||||
bottom: 5.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 999;
|
||||
width: min(360px, calc(100vw - 2rem));
|
||||
max-height: min(520px, calc(100vh - 8rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
animation: chatSlideUp 0.22s ease;
|
||||
}
|
||||
|
||||
.chatbot-page {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(201, 168, 76, 0.14), transparent 40%),
|
||||
var(--brand-black);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.chatbot-panel--standalone {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
width: min(780px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
min-height: calc(100vh - 2.5rem);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chatbot-panel--standalone .chatbot-messages {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.chatbot-panel--standalone .chatbot-msg {
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
@keyframes chatSlideUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.chatbot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.25);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #e8c87a;
|
||||
}
|
||||
|
||||
.chatbot-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.chatbot-header-btn {
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
border: 1px solid rgba(201, 168, 76, 0.28);
|
||||
color: #d9bc7a;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.72rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chatbot-header-btn:hover {
|
||||
background: rgba(201, 168, 76, 0.22);
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
color: #f4ddb0;
|
||||
}
|
||||
|
||||
.chatbot-header-btn--link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatbot-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--brand-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.chatbot-close:hover { color: #e8c87a; }
|
||||
|
||||
/* Message list */
|
||||
.chatbot-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.chatbot-msg {
|
||||
max-width: 85%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chatbot-msg-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.chatbot-msg p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chatbot-msg p + p {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.chatbot-msg--bot {
|
||||
background: rgba(201, 168, 76, 0.12);
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
color: #e8d8b0;
|
||||
align-self: flex-start;
|
||||
border-bottom-left-radius: 0.15rem;
|
||||
}
|
||||
|
||||
.chatbot-msg--user {
|
||||
background: rgba(80, 80, 80, 0.45);
|
||||
color: #ddd;
|
||||
align-self: flex-end;
|
||||
border-bottom-right-radius: 0.15rem;
|
||||
}
|
||||
|
||||
/* Typing dots */
|
||||
.chatbot-msg--typing {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.chatbot-msg--typing span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-gold);
|
||||
animation: typingDot 1.2s infinite;
|
||||
}
|
||||
|
||||
.chatbot-msg--typing span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chatbot-msg--typing span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typingDot {
|
||||
0%, 80%, 100% { opacity: 0.2; transform: scale(0.85); }
|
||||
40% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Suggested prompts */
|
||||
.chatbot-prompts {
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chatbot-prompt-btn {
|
||||
background: rgba(201, 168, 76, 0.1);
|
||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||
color: var(--brand-gold);
|
||||
border-radius: 1rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.chatbot-prompt-btn:hover {
|
||||
background: rgba(201, 168, 76, 0.22);
|
||||
border-color: var(--brand-gold);
|
||||
}
|
||||
|
||||
/* Input row */
|
||||
.chatbot-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.2);
|
||||
padding: 0.6rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chatbot-style-select {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
color: #d9cba8;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
max-width: 6.8rem;
|
||||
}
|
||||
|
||||
.chatbot-style-select:focus {
|
||||
border-color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.chatbot-input {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
border-radius: 0.4rem;
|
||||
color: #e8d8b0;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.chatbot-input::placeholder { color: #7a6a50; }
|
||||
.chatbot-input:focus { border-color: var(--brand-gold); }
|
||||
|
||||
.chatbot-send {
|
||||
background: var(--brand-gold);
|
||||
border: none;
|
||||
border-radius: 0.4rem;
|
||||
color: #fff;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
|
||||
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
/* New here page cards */
|
||||
.start-grid {
|
||||
display: grid;
|
||||
|
||||
+403
-602
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface ContactFields {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: string
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
const navigate = useNavigate()
|
||||
const [fields, setFields] = useState<ContactFields>({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
message: '',
|
||||
messageType: 'question',
|
||||
})
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('submitting')
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
navigate('/thanks')
|
||||
} catch {
|
||||
setErrorMsg('Could not connect. Please try again later.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
||||
<input
|
||||
type="text"
|
||||
className="contact-honeypot"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
value={honey}
|
||||
onChange={e => setHoney(e.target.value)}
|
||||
/>
|
||||
<label>
|
||||
First Name
|
||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Last Name
|
||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Message Type
|
||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
||||
<option value="question">Bible Question</option>
|
||||
<option value="testimony">Testimony</option>
|
||||
<option value="topic">Topic Request</option>
|
||||
<option value="general">General Message</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Message
|
||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||
</label>
|
||||
<label className="contact-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subscribe}
|
||||
onChange={e => setSubscribe(e.target.checked)}
|
||||
/>
|
||||
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||
</label>
|
||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
|
||||
interface PublicQuestion {
|
||||
id: string
|
||||
firstName: string
|
||||
question: string
|
||||
answer: string
|
||||
topic?: string
|
||||
}
|
||||
|
||||
const QA_PAGE_SIZE = 6
|
||||
|
||||
function tokenizeForRelated(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 3)
|
||||
}
|
||||
|
||||
export default function QASection() {
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
||||
const [page, setPage] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/questions')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||||
.then(data => {
|
||||
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
||||
|
||||
const filteredQuestions = questions.filter(q => {
|
||||
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesTopic && matchesSearch
|
||||
})
|
||||
|
||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(state => ({ ...state, [id]: !state[id] }))
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleTopic = (topic: string | null) => {
|
||||
setSelectedTopic(topic)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const getRelatedQuestions = (current: PublicQuestion) => {
|
||||
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
||||
|
||||
return questions
|
||||
.filter(candidate => candidate.id !== current.id)
|
||||
.map(candidate => {
|
||||
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
||||
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
||||
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
||||
const score = overlap + (sameTopic ? 5 : 0)
|
||||
return { candidate, score }
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map(item => item.candidate)
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||
<div className="section-inner">
|
||||
<h2 className="section-heading">
|
||||
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="qa-no-results">Loading questions…</p>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-filters">
|
||||
<div className="qa-topics">
|
||||
<button
|
||||
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{topics.map(topic => (
|
||||
<button
|
||||
key={topic}
|
||||
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(topic)}
|
||||
>
|
||||
{topic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qa-search">
|
||||
<label>
|
||||
<span className="visually-hidden">Search Questions</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions…"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-cards">
|
||||
{pagedQuestions.map(question => (
|
||||
<div key={question.id} className="qa-card-scene">
|
||||
<div
|
||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
||||
onClick={() => toggleExpanded(question.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!!expanded[question.id]}
|
||||
aria-label={question.question}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
|
||||
}}
|
||||
>
|
||||
<div className="qa-card-face qa-card-front">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<p className="qa-question-text">{question.question}</p>
|
||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
<div className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<p className="qa-answer-text">{question.answer}</p>
|
||||
{getRelatedQuestions(question).length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
<div className="qa-related-list">
|
||||
{getRelatedQuestions(question).map(related => (
|
||||
<button
|
||||
key={related.id}
|
||||
type="button"
|
||||
className="qa-related-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTopic(null)
|
||||
handleSearch(related.question)
|
||||
setExpanded({ [related.id]: true })
|
||||
}}
|
||||
>
|
||||
{related.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
|
||||
— Answered by Nate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="qa-pagination">
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 0}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span className="qa-page-info">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
export interface CustomLink {
|
||||
id: string
|
||||
label: string
|
||||
url: string
|
||||
placement: 'platforms' | 'footer' | 'resources'
|
||||
imageUrl?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface CustomBlock {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesResourceLink {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesNote {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeries {
|
||||
id: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
listenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
resourceLinks: ArchivedSeriesResourceLink[]
|
||||
notes: ArchivedSeriesNote[]
|
||||
}
|
||||
|
||||
export interface RedirectRule {
|
||||
id: string
|
||||
path: string
|
||||
target: string
|
||||
statusCode: 301 | 302
|
||||
}
|
||||
|
||||
export interface PodcastFeaturedLink {
|
||||
id: string
|
||||
title: string
|
||||
episodeNumber?: string
|
||||
summary: string
|
||||
url: string
|
||||
embedUrl?: string
|
||||
showNotes?: string
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
|
||||
export interface SeoSettings {
|
||||
title: string
|
||||
description: string
|
||||
ogTitle: string
|
||||
ogDescription: string
|
||||
ogImage: string
|
||||
canonicalUrl: string
|
||||
robotsPolicy: string
|
||||
sitemapPaths: string[]
|
||||
}
|
||||
|
||||
export interface LegalSettings {
|
||||
privacyTitle: string
|
||||
privacyBody: string[]
|
||||
termsTitle: string
|
||||
termsBody: string[]
|
||||
}
|
||||
|
||||
export interface SiteContent {
|
||||
eyebrow: string
|
||||
heroTagline: string
|
||||
startHereHeading: string
|
||||
startHereIntro: string
|
||||
startHereStep1Title: string
|
||||
startHereStep1Body: string
|
||||
startHereStep1Cta: string
|
||||
startHereStep2Title: string
|
||||
startHereStep2Body: string
|
||||
startHereStep2Cta: string
|
||||
startHereStep3Title: string
|
||||
startHereStep3Body: string
|
||||
startHereStep3Cta: string
|
||||
aboutShowHeading: string
|
||||
aboutShowP1: string
|
||||
aboutShowP2: string
|
||||
aboutNate: string
|
||||
aboutPhotoUrl: string
|
||||
aboutVerseArtUrl: string
|
||||
contactPhotoUrl: string
|
||||
seriesLabel: string
|
||||
seriesTitle: string
|
||||
seriesDescription: string
|
||||
seriesImageUrl: string
|
||||
seriesListenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideDownloadUrl: string
|
||||
studyGuideUrl: string
|
||||
shareHeading: string
|
||||
shareP: string
|
||||
customLinks: CustomLink[]
|
||||
customBlocks: CustomBlock[]
|
||||
archivedSeries: ArchivedSeries[]
|
||||
redirects: RedirectRule[]
|
||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||
seo: SeoSettings
|
||||
legal: LegalSettings
|
||||
}
|
||||
|
||||
export const DEFAULTS: SiteContent = {
|
||||
eyebrow: 'A Journey Through Scripture',
|
||||
heroTagline: "Exploring God's Word one verse at a time",
|
||||
startHereHeading: 'New Here? Start Here',
|
||||
startHereIntro:
|
||||
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
|
||||
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
|
||||
startHereStep1Body:
|
||||
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
|
||||
startHereStep1Cta: 'Open Episodes',
|
||||
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
|
||||
startHereStep2Body:
|
||||
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
|
||||
startHereStep2Cta: 'Go to Q&A',
|
||||
startHereStep3Title: 'Step 3: Ask Nate Directly',
|
||||
startHereStep3Body:
|
||||
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
|
||||
startHereStep3Cta: 'Submit a Question',
|
||||
aboutShowHeading: 'Depth. Clarity. Application.',
|
||||
aboutShowP1:
|
||||
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
|
||||
aboutShowP2:
|
||||
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
aboutNate:
|
||||
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
aboutPhotoUrl: '/images/nate-photo.jpeg',
|
||||
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
|
||||
contactPhotoUrl: '/images/nate-contact-photo.png',
|
||||
seriesLabel: 'Now Playing',
|
||||
seriesTitle: 'Study of Titus: Sound Doctrine',
|
||||
seriesDescription:
|
||||
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
seriesImageUrl: '/images/titus-cover.png',
|
||||
seriesListenUrl: '/spotify',
|
||||
studyGuideTitle: 'Companion Study Guide',
|
||||
studyGuideDescription:
|
||||
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
|
||||
studyGuideDownloadUrl: '',
|
||||
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
|
||||
shareHeading: 'Help one more person hear the Word this week.',
|
||||
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
||||
customLinks: [],
|
||||
customBlocks: [],
|
||||
archivedSeries: [],
|
||||
redirects: [
|
||||
{
|
||||
id: 'spotify',
|
||||
path: '/spotify',
|
||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'apple',
|
||||
path: '/apple',
|
||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
{
|
||||
id: 'amazon',
|
||||
path: '/amazon',
|
||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||
statusCode: 301,
|
||||
},
|
||||
],
|
||||
podcastFeaturedLinks: [],
|
||||
seo: {
|
||||
title: 'Verse by Verse with Nate',
|
||||
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
ogTitle: 'Verse by Verse with Nate',
|
||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||
ogImage: '/images/podcast-art.jpeg',
|
||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||
robotsPolicy: 'index,follow',
|
||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||
},
|
||||
legal: {
|
||||
privacyTitle: 'Privacy Policy',
|
||||
privacyBody: [
|
||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||
],
|
||||
termsTitle: 'Terms',
|
||||
termsBody: [
|
||||
'Content on this site is for informational and ministry purposes.',
|
||||
'External links are provided for convenience and are subject to third-party policies.',
|
||||
'By using this site, you agree to lawful use and respectful communication.',
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function FacebookIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpotifyIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function YouTubeIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AmazonMusicIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user