Compare commits
163
Commits
29d2ace1b2
...
main
No files matched your search
Submodule .claude/worktrees/agent-a3dddb4d7aa52218e deleted from e17a818deb.
@@ -0,0 +1,71 @@
|
||||
name: Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "beta"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Run production build
|
||||
run: npm run build
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.necloud.us
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.necloud.us/nate/siteforge
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||
type=sha
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
BUILD_NUMBER=${{ github.run_number }}
|
||||
@@ -2,7 +2,7 @@ name: Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["main", "beta"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Run production build
|
||||
run: npm run build
|
||||
|
||||
@@ -50,6 +56,7 @@ jobs:
|
||||
images: git.necloud.us/nate/siteforge
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||
type=sha
|
||||
type=ref,event=tag
|
||||
|
||||
@@ -63,3 +70,4 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
BUILD_NUMBER=${{ github.run_number }}
|
||||
+27
@@ -23,11 +23,15 @@ data/visitor-stats.json
|
||||
data/contact-submissions.json
|
||||
data/totp-secret.json
|
||||
data/uploads-meta.json
|
||||
data/study-sessions.json
|
||||
|
||||
# Local environment secrets — never commit
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Claude Code agent worktrees — local scratch space, never part of the repo
|
||||
.claude/worktrees/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -38,3 +42,26 @@ data/uploads-meta.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Persisted admin sessions (hashed tokens)
|
||||
data/admin-sessions.json
|
||||
|
||||
# Per-user / visitor data — lives only in the server's data volume, never in git
|
||||
data/study-users.json
|
||||
data/study-notes/
|
||||
data/study-progress/
|
||||
data/study-reminders.json
|
||||
data/study-community.json
|
||||
data/analytics-events.json
|
||||
data/admin-reply-history.json
|
||||
data/episode-plays.json
|
||||
|
||||
# Episode workflow progress — lives only in the server data volume
|
||||
data/podcast-workflow.json
|
||||
data/nuggets-workflow.json
|
||||
|
||||
# Live download counters — server data volume only
|
||||
data/download-counts.json
|
||||
|
||||
# Generated audio (upload via Admin → Asset Manager, do not commit)
|
||||
audio-out/
|
||||
+5
-1
@@ -12,7 +12,9 @@ ENV NODE_ENV=production
|
||||
ENV PORT=4173
|
||||
|
||||
ARG COMMIT_SHA=unknown
|
||||
ENV COMMIT_SHA=${COMMIT_SHA}
|
||||
ARG BUILD_NUMBER=unknown
|
||||
ENV BUILD_COMMIT=${COMMIT_SHA}
|
||||
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
@@ -33,4 +35,6 @@ COPY entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
|
||||
EXPOSE 4173
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/api/admin-auth/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
@@ -141,7 +141,6 @@ siteforge/
|
||||
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
|
||||
| `GET /api/admin/qr-codes` | QR code management |
|
||||
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
|
||||
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
|
||||
| `GET /api/admin-subscribers` | Subscriber list |
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"seriesImageUrl": "/images/titus-cover.png",
|
||||
"seriesListenUrl": "/spotify",
|
||||
"seriesListenBtnLabel": "Listen to Series",
|
||||
"seriesDownloadsBtnLabel": "View Downloads",
|
||||
"seriesDownloadsBtnLabel": "View Resources",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideDownloadUrl": "",
|
||||
@@ -1007,7 +1007,7 @@
|
||||
],
|
||||
"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.",
|
||||
"prismVideoUrl": "/images/PRISM.mp4",
|
||||
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
|
||||
"prismEyebrow": "Featured Video",
|
||||
"prismHeading": "PRISM",
|
||||
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
||||
@@ -1030,7 +1030,7 @@
|
||||
},
|
||||
{
|
||||
"id": "downloads",
|
||||
"title": "Downloads",
|
||||
"title": "Resources",
|
||||
"description": "Main guide, extra downloads, and past study files.",
|
||||
"path": "/resources"
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"seriesImageUrl": "/images/titus-cover.png",
|
||||
"seriesListenUrl": "/spotify",
|
||||
"seriesListenBtnLabel": "Listen to Series",
|
||||
"seriesDownloadsBtnLabel": "View Downloads",
|
||||
"seriesDownloadsBtnLabel": "View Resources",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideDownloadUrl": "",
|
||||
@@ -1061,7 +1061,7 @@
|
||||
"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.",
|
||||
"shareQrImageUrl": "/images/qr-code.jpg",
|
||||
"prismVideoUrl": "/images/PRISM.mp4",
|
||||
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
|
||||
"prismEyebrow": "Featured Video",
|
||||
"prismHeading": "PRISM",
|
||||
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
||||
@@ -1084,7 +1084,7 @@
|
||||
},
|
||||
{
|
||||
"id": "downloads",
|
||||
"title": "Downloads",
|
||||
"title": "Resources",
|
||||
"description": "Main guide, extra downloads, and past study files.",
|
||||
"path": "/resources"
|
||||
},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a",
|
||||
"submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713",
|
||||
"toEmail": "hello@versebyversewithnate.us",
|
||||
"toName": "NoAdmin Signup",
|
||||
"fromEmail": "hello@versebyversewithnate.us",
|
||||
"subject": "Thanks for reaching out to Verse by Verse with Nate",
|
||||
"preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate",
|
||||
"sentAt": "2026-05-07T12:54:06.683Z"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-05-07T12:54:06.685Z"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"scrollDepth": {
|
||||
"/episodes": {
|
||||
"25": 4,
|
||||
"50": 4,
|
||||
"75": 2,
|
||||
"90": 0
|
||||
},
|
||||
"/": {
|
||||
"25": 2,
|
||||
"50": 2,
|
||||
"75": 2,
|
||||
"90": 2
|
||||
},
|
||||
"/about": {
|
||||
"25": 6,
|
||||
"50": 6,
|
||||
"75": 6,
|
||||
"90": 6
|
||||
}
|
||||
},
|
||||
"timeOnPage": {
|
||||
"/": {
|
||||
"totalSeconds": 64,
|
||||
"count": 5
|
||||
},
|
||||
"/episodes": {
|
||||
"totalSeconds": 318967,
|
||||
"count": 305
|
||||
},
|
||||
"/episodes/movn6tk3": {
|
||||
"totalSeconds": 445,
|
||||
"count": 3
|
||||
},
|
||||
"/about": {
|
||||
"totalSeconds": 63497,
|
||||
"count": 128
|
||||
}
|
||||
},
|
||||
"outboundClicks": {},
|
||||
"linkClicks": {
|
||||
"https://ko-fi.com/versebyversewithnate": {
|
||||
"count": 1,
|
||||
"internal": false
|
||||
},
|
||||
"/": {
|
||||
"count": 1,
|
||||
"internal": true
|
||||
},
|
||||
"/about": {
|
||||
"count": 1,
|
||||
"internal": true
|
||||
}
|
||||
},
|
||||
"utmSources": {},
|
||||
"searchQueries": {},
|
||||
"notFound": {},
|
||||
"audioEvents": {
|
||||
"Living Between Two Appearings": {
|
||||
"pauses": 0,
|
||||
"completions": 0,
|
||||
"totalListenSeconds": 43
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Living Between Two Appearings": {
|
||||
"total": 1,
|
||||
"byDay": {
|
||||
"2026-07-22": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
{
|
||||
"checklist": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "verify_script",
|
||||
"label": "Verify Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "read_script",
|
||||
"label": "Read Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "record",
|
||||
"label": "Record",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Edit",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "mix",
|
||||
"label": "Mix",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "video_script",
|
||||
"label": "Run Video Conversion Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "post_spotify",
|
||||
"label": "Post on Spotify",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "update_website",
|
||||
"label": "Update Website",
|
||||
"phase": "post"
|
||||
},
|
||||
{
|
||||
"id": "send_email",
|
||||
"label": "Send Email",
|
||||
"phase": "post"
|
||||
}
|
||||
],
|
||||
"episodes": [
|
||||
{
|
||||
"id": "titus-11",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 11,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-12",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 12,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-13",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 13,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-14",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 14,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-15",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 15,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-1",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 1,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-2",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 2,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-3",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 3,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-4",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 4,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-5",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 5,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-6",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 6,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-7",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 7,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-8",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 8,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-9",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 9,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-10",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 10,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-11",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 11,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-12",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 12,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-13",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 13,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-14",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 14,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-15",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 15,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-16",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 16,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-17",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 17,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-18",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 18,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-19",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 19,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-20",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 20,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-21",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 21,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-22",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 22,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-23",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 23,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-24",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 24,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-25",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 25,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-26",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 26,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-27",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 27,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "episode-mpb66z1c",
|
||||
"series": "Titus -10",
|
||||
"episodeNumber": 0,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"updatedAt": "2026-05-18T12:18:35.130Z"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"posts": [
|
||||
{
|
||||
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "reading",
|
||||
"createdAt": "2026-06-02T17:41:48.684Z",
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "sool",
|
||||
"createdAt": "2026-06-02T17:41:41.608Z",
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:16.795Z",
|
||||
"replies": [
|
||||
{
|
||||
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:25.003Z"
|
||||
},
|
||||
{
|
||||
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:34.406Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-06-02T17:41:48.685Z"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"colossians--1-1-2": "test....teset"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"1-1-2": "Test"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"byStudy": {
|
||||
"colossians": {
|
||||
"completedSectionIds": [
|
||||
"1-1-2",
|
||||
"1-3-8"
|
||||
],
|
||||
"quizAnswers": {
|
||||
"1-1-2": [
|
||||
"test",
|
||||
"test"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-06-08T20:30:19.689Z"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"users": {},
|
||||
"updatedAt": "2026-09-03T21:13:33.725Z"
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4",
|
||||
"username": "student.test@versebyverse.local",
|
||||
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
|
||||
"displayName": "Student Test",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [],
|
||||
"avatarUrl": "",
|
||||
"createdAt": null,
|
||||
"updatedAt": "2026-08-10T12:39:25.413Z",
|
||||
"lastLoginAt": "2026-05-21T15:41:16.259Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": [],
|
||||
"remindersPausedUntil": null,
|
||||
"reminderFrequency": "immediate",
|
||||
"weeklyDigestSentAt": null
|
||||
},
|
||||
{
|
||||
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"username": "test.user@versebyverse.local",
|
||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||
"displayName": "Test Student",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [
|
||||
"colossians"
|
||||
],
|
||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
|
||||
"createdAt": null,
|
||||
"updatedAt": "2026-06-08T20:46:30.804Z",
|
||||
"lastLoginAt": "2026-06-08T20:46:30.804Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": [],
|
||||
"totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5",
|
||||
"remindersPausedUntil": null,
|
||||
"reminderFrequency": "immediate",
|
||||
"weeklyDigestSentAt": null
|
||||
},
|
||||
{
|
||||
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
||||
"username": "test.avatar@versebyverse.local",
|
||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||
"displayName": "Avatar Test",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [],
|
||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
|
||||
"createdAt": null,
|
||||
"updatedAt": null,
|
||||
"lastLoginAt": "2026-06-02T17:33:13.669Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": [],
|
||||
"remindersPausedUntil": null,
|
||||
"reminderFrequency": "immediate",
|
||||
"weeklyDigestSentAt": null
|
||||
},
|
||||
{
|
||||
"id": "6cd08599-b8a8-4aa9-809d-dd606cf21635",
|
||||
"username": "nmemmert@outlook.com",
|
||||
"passwordHash": "534c7ff8f203149c82881728eada86729bc69ef069db2f4b8f7202d7cf8b11f9",
|
||||
"displayName": "",
|
||||
"subscribeNewsletter": true,
|
||||
"studyRemindersEnabled": false,
|
||||
"pendingEmailChange": null,
|
||||
"enrolledStudySlugs": [
|
||||
"colossians",
|
||||
"titus"
|
||||
],
|
||||
"createdAt": "2026-09-03T20:14:39.297Z",
|
||||
"updatedAt": "2026-09-03T20:14:49.258Z",
|
||||
"lastLoginAt": "2026-09-03T20:14:39.297Z",
|
||||
"firstVisitAt": "2026-09-03T20:14:51.933Z"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-09-03T20:14:51.933Z"
|
||||
}
|
||||
@@ -10,6 +10,13 @@ services:
|
||||
- SITEFORGE_DATA_DIR=/app/data
|
||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||
- RESEND_API_KEY=`set your Resend API key here`
|
||||
# Seafile integration for Nuggets host upload portal
|
||||
# Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file
|
||||
- SEAFILE_URL=https://seafile.necloud.us
|
||||
- SEAFILE_TOKEN=${SEAFILE_TOKEN}
|
||||
- SEAFILE_NUGGETS_REPO_ID=${SEAFILE_NUGGETS_REPO_ID}
|
||||
# Upload notification email (defaults to RESEND_TO if not set)
|
||||
- NUGGETS_NOTIFY_EMAIL=${NUGGETS_NOTIFY_EMAIL}
|
||||
volumes:
|
||||
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||
restart: unless-stopped
|
||||
+6
-7
@@ -1,11 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
|
||||
SEED_DIR=/app/data-seed
|
||||
|
||||
# Ensure the data directory exists (in case the volume was not mounted)
|
||||
mkdir -p "$DATA_DIR"
|
||||
mkdir -p "$DATA_DIR" || true
|
||||
|
||||
# Seed each default JSON file only when missing.
|
||||
# This runs on first boot or a fresh volume, but never overwrites existing data.
|
||||
@@ -18,7 +17,7 @@ if [ -d "$SEED_DIR" ]; then
|
||||
|
||||
if [ ! -f "$target_file" ]; then
|
||||
echo "[siteforge] No $base_name found. Seeding from image defaults..."
|
||||
cp "$seed_file" "$target_file"
|
||||
cp "$seed_file" "$target_file" || echo "[siteforge] Warning: could not seed $base_name — check volume permissions on $DATA_DIR"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
@@ -99,8 +98,8 @@ console.log(`[siteforge] Merged seeded platform links into ${targetFile}`)
|
||||
NODE
|
||||
}
|
||||
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
|
||||
|
||||
# Backfill missing lesson releasedAt values from seeded defaults on upgrades.
|
||||
# This keeps persistent volumes compatible with release-date access gating.
|
||||
@@ -207,8 +206,8 @@ console.log(`[siteforge] Backfilled missing releasedAt values in ${targetFile}`)
|
||||
NODE
|
||||
}
|
||||
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
|
||||
|
||||
export SITEFORGE_DATA_DIR="$DATA_DIR"
|
||||
exec node server.js
|
||||
@@ -19,5 +19,9 @@ export default defineConfig([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
// Reset-state-on-prop-change effects are used throughout; flagged for gradual cleanup rather than blocking lint.
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
Generated
+1909
-1300
File diff suppressed because it is too large.
Load diff
+10
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.46.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,20 +10,26 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node --env-file=.env server.js",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test test/*.test.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"@tiptap/core": "^3.31.4",
|
||||
"@tiptap/pm": "^3.31.4",
|
||||
"@tiptap/react": "^3.31.4",
|
||||
"@tiptap/starter-kit": "^3.31.4",
|
||||
"chart.js": "^4.4.0",
|
||||
"docx": "^9.6.1",
|
||||
"dompurify": "^3.4.16",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"multer": "^2.3.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -32,7 +38,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"react-simple-maps": "^5.0.5",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.10.0",
|
||||
"tar": "^7.5.19"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Siteforge TODO
|
||||
|
||||
Open gaps found in the beta audit (2026-10-09). Items already shipped are in git history (v2.10.0 – v2.10.2).
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] **Email 2FA codes use `Math.random()`** — switch `generateEmailOtp` in `server/study-helpers.js` to `crypto.randomInt`; compare hashes with `timingSafeEqual`.
|
||||
- [ ] **TOTP replay** — `verifyTotpCode` in `server/auth.js` accepts a code again within its ±1 step window. Record the last accepted time step per secret (admin + study users) and reject anything at or before it.
|
||||
- [ ] **`unhandledRejection` / `uncaughtException` handlers** in `server.js` — Node 22 exits on an unhandled rejection; log and (for rejections) keep running.
|
||||
- [ ] **Scrub old user data from git history** — `data/study-users.json` (old SHA-256 password hashes), study notes/progress etc. were committed before being untracked in v2.10.0. Needs a history rewrite (`git filter-repo`) + force-push; old hashes remain recoverable until then.
|
||||
- [ ] **CSP `'unsafe-inline'` in `script-src`** — needs a nonce setup for the Vite build to remove.
|
||||
|
||||
## Frontend / performance
|
||||
|
||||
- [ ] **Code splitting** — no `lazy()`/`Suspense` anywhere; one 1.7 MB JS bundle includes the 7,000-line `AdminPage`. Lazy-load `AdminPage` and the study pages.
|
||||
- [ ] **React error boundary** — a render error currently white-screens the page; add a top-level boundary with a fallback.
|
||||
- [ ] **Per-route SEO / link previews** — OG tags are fixed in `index.html` and titles are set client-side (`usePageMeta`), so non-JS crawlers (iMessage, Facebook, Slack) show the generic preview for `/episodes/:id` and study lesson URLs. Inject per-route title/description/OG tags server-side.
|
||||
- [ ] **Accessibility audit** — run axe (contrast, focus order, modal dialogs, image alt text).
|
||||
|
||||
## Maintenance
|
||||
|
||||
- [ ] **Split the large files** — `AdminPage.tsx` (~7,000 lines), `studyPages.tsx` (~4,200), `App.tsx` (~3,600).
|
||||
- [ ] **Clear the 11 `react-hooks/set-state-in-effect` lint warnings** (rule downgraded to `warn` in v2.10.0), then restore it to `error`.
|
||||
- [ ] **Remaining npm audit findings** — `d3-color` (high, via `react-simple-maps`) and `sprintf-js` (moderate, via `mammoth`; fix is a breaking downgrade).
|
||||
- [ ] **CI installs with `--legacy-peer-deps`** — resolve the tiptap peer-dependency conflict so plain `npm ci` works.
|
||||
- [ ] **Resend webhook `?token=` query form** — in logs/URLs; kept because Resend can't send custom headers. Revisit if Resend adds header support or switch to svix signature verification.
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV PORT=3000
|
||||
ENV DATA_FILE=/data/progress.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,63 +0,0 @@
|
||||
# Podcast Checklist
|
||||
|
||||
Episode production tracker for *Verse by Verse with Nate*.
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Build and push to GHCR
|
||||
|
||||
```bash
|
||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
### 2. On the Oracle Cloud VM
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
||||
docker rm -f podcast-checklist 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name podcast-checklist \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
-v podcast-checklist-data:/data \
|
||||
ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
> Uses port **3001** on the host so it doesn't clash with your existing site on 3000.
|
||||
> Progress is saved to a named Docker volume (`podcast-checklist-data`) so it survives container restarts and re-deploys.
|
||||
|
||||
### 3. Cloudflare DNS
|
||||
|
||||
Add a CNAME record in Cloudflare:
|
||||
- **Name:** `checklist` (or whatever subdomain you want)
|
||||
- **Target:** `versebyversewithnate.us`
|
||||
- **Proxy:** Enabled (orange cloud)
|
||||
|
||||
### 4. Cloudflare Tunnel or Nginx proxy
|
||||
|
||||
If you're using Nginx Proxy Manager or a Cloudflare Tunnel on the VM, add a rule to forward `checklist.versebyversewithnate.us` → `localhost:3001`.
|
||||
|
||||
---
|
||||
|
||||
## Update workflow
|
||||
|
||||
Same as your site:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
||||
|
||||
# SSH into VM
|
||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
||||
docker rm -f podcast-checklist
|
||||
docker run -d \
|
||||
--name podcast-checklist \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
-v podcast-checklist-data:/data \
|
||||
ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
Progress data is in the named volume and is **not affected** by pulling a new image.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "podcast-checklist",
|
||||
"version": "1.0.0",
|
||||
"description": "Verse by Verse with Nate — Episode Production Checklist",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Episode Checklist — Verse by Verse with Nate</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,600&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--black: #0d0d0a;
|
||||
--card-bg: #111108;
|
||||
--border: #1e1c14;
|
||||
--border-mid: #2a2518;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e0c070;
|
||||
--warm-white: #f0ead8;
|
||||
--warm-gray: #7a7060;
|
||||
--muted: #4a4438;
|
||||
--dim: #3a3020;
|
||||
--deep: #252018;
|
||||
}
|
||||
|
||||
body { background: var(--black); color: var(--warm-white); font-family: 'Cormorant Garamond', Georgia, serif; min-height: 100vh; }
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: var(--black); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-mid); }
|
||||
|
||||
.root { max-width: 720px; margin: 0 auto; padding: 48px 20px 100px; }
|
||||
|
||||
.masthead { text-align: center; margin-bottom: 40px; }
|
||||
.eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--warm-gray); margin-bottom: 10px; }
|
||||
.main-title { font-size: clamp(28px, 6vw, 44px); font-weight: 300; font-style: italic; color: var(--gold); line-height: 1.1; margin-bottom: 4px; }
|
||||
.subtitle { font-size: 13px; color: var(--muted); letter-spacing: 0.1em; }
|
||||
.gold-rule { width: 80px; height: 1px; background: linear-gradient(90deg, transparent, var(--gold), transparent); margin: 18px auto; }
|
||||
|
||||
.save-status { text-align: center; font-size: 11px; letter-spacing: 0.12em; color: var(--dim); margin-bottom: 24px; height: 16px; transition: color 0.3s; }
|
||||
.save-status.saving { color: var(--warm-gray); }
|
||||
.save-status.saved { color: var(--gold); }
|
||||
.save-status.error { color: #8b3030; }
|
||||
|
||||
.overall { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; padding: 18px 22px; margin-bottom: 32px; }
|
||||
.overall-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
|
||||
.overall-label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); }
|
||||
.overall-pct { font-size: 28px; font-weight: 300; color: var(--gold); }
|
||||
.bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 14px; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); border-radius: 2px; transition: width 0.4s ease; }
|
||||
.series-stats { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
.stat { font-size: 12px; color: var(--muted); }
|
||||
.stat b { color: var(--gold); font-weight: 500; }
|
||||
|
||||
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.filter-btn { font-family: 'Cormorant Garamond', serif; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; background: none; border: 1px solid var(--border-mid); color: var(--muted); padding: 6px 14px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
||||
.filter-btn:hover { color: var(--gold); border-color: var(--dim); }
|
||||
.filter-btn.active { color: var(--gold); border-color: var(--gold); background: var(--card-bg); }
|
||||
|
||||
.section-label { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--dim); margin: 28px 0 12px; padding-left: 2px; }
|
||||
|
||||
/* Card */
|
||||
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s; }
|
||||
.card:hover { border-color: var(--border-mid); }
|
||||
.card.complete { border-left: 2px solid var(--gold); }
|
||||
|
||||
.card-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer; user-select: none; }
|
||||
|
||||
.series-tag { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; padding: 2px 7px; border-radius: 1px; flex-shrink: 0; }
|
||||
.tag-Titus { color: var(--gold); background: #1a1810; border: 1px solid var(--border-mid); }
|
||||
.tag-Colossians { color: var(--warm-gray); background: #131310; border: 1px solid var(--border); }
|
||||
|
||||
.ep-num { font-size: 13px; font-weight: 600; color: var(--muted); flex-shrink: 0; min-width: 36px; }
|
||||
.card.complete .ep-num { color: var(--gold); }
|
||||
|
||||
.ep-label { flex: 1; font-size: 15px; color: #c8c0a8; }
|
||||
.card.complete .ep-label { color: var(--muted); }
|
||||
|
||||
.pub-date-pill { font-size: 10px; color: var(--warm-gray); font-style: italic; flex-shrink: 0; letter-spacing: 0.03em; }
|
||||
|
||||
.task-count { font-size: 11px; color: var(--dim); flex-shrink: 0; }
|
||||
.card.complete .task-count { color: var(--gold); }
|
||||
|
||||
.mini-bar { width: 48px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; flex-shrink: 0; }
|
||||
.mini-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); transition: width 0.3s; }
|
||||
|
||||
.chevron { font-size: 10px; color: var(--border-mid); transition: transform 0.2s, color 0.15s; flex-shrink: 0; }
|
||||
.chevron.open { transform: rotate(180deg); color: var(--muted); }
|
||||
|
||||
.card-body { border-top: 1px solid #1a1810; padding: 0 18px 18px; }
|
||||
|
||||
/* Date published row */
|
||||
.date-row { display: flex; align-items: center; gap: 12px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
||||
.date-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); flex-shrink: 0; }
|
||||
.date-input {
|
||||
background: transparent; border: none; border-bottom: 1px solid var(--border-mid);
|
||||
outline: none; font-family: 'Cormorant Garamond', serif; font-size: 14px;
|
||||
color: var(--gold-light); padding: 2px 6px 3px; cursor: pointer;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.date-input:focus { border-bottom-color: var(--gold); }
|
||||
.date-input::-webkit-calendar-picker-indicator { filter: invert(0.6) sepia(1) saturate(2) hue-rotate(5deg); cursor: pointer; }
|
||||
|
||||
/* Phase headers */
|
||||
.phase-header { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: var(--warm-gray); padding: 14px 0 6px; border-bottom: 1px solid var(--border); margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
|
||||
.phase-header::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.phase-pre { color: var(--warm-gray); }
|
||||
.phase-post { color: var(--gold); }
|
||||
|
||||
.task-list { list-style: none; }
|
||||
.task-item { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-bottom: 1px solid #141210; cursor: pointer; }
|
||||
.task-item:last-child { border-bottom: none; }
|
||||
|
||||
.check-box { width: 16px; height: 16px; border: 1px solid var(--border-mid); border-radius: 1px; flex-shrink: 0; margin-top: 3px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; }
|
||||
.task-item.done .check-box { background: var(--gold); border-color: var(--gold); }
|
||||
.check-svg { display: none; }
|
||||
.task-item.done .check-svg { display: block; }
|
||||
|
||||
.step-num { font-size: 9px; letter-spacing: 0.15em; color: var(--border-mid); margin-top: 4px; flex-shrink: 0; width: 22px; }
|
||||
|
||||
.task-body { flex: 1; }
|
||||
.task-name { font-size: 14px; font-weight: 500; color: #d8d0b8; line-height: 1.3; transition: color 0.15s; }
|
||||
.task-item.done .task-name { color: var(--dim); text-decoration: line-through; text-decoration-color: var(--border-mid); }
|
||||
.task-hint { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 1px; }
|
||||
.task-item.done .task-hint { color: var(--deep); }
|
||||
.task-item:hover .task-name { color: var(--warm-white); }
|
||||
.task-item.done:hover .task-name { color: var(--dim); }
|
||||
|
||||
.card-footer { display: flex; justify-content: flex-end; padding-top: 10px; margin-top: 4px; border-top: 1px solid #141210; }
|
||||
.btn-reset { font-family: 'Cormorant Garamond', serif; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; background: none; border: 1px solid var(--border); color: var(--dim); padding: 4px 10px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-reset:hover { color: var(--warm-gray); border-color: var(--border-mid); }
|
||||
.complete-badge { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gold); padding: 2px 8px; border: 1px solid var(--border-mid); border-radius: 1px; }
|
||||
|
||||
.loading { text-align: center; color: var(--muted); font-style: italic; padding: 40px; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="root">
|
||||
|
||||
<div class="masthead">
|
||||
<div class="eyebrow">Verse by Verse with Nate</div>
|
||||
<div class="main-title">Production Checklist</div>
|
||||
<div class="subtitle">Titus · Colossians</div>
|
||||
<div class="gold-rule"></div>
|
||||
</div>
|
||||
|
||||
<div class="save-status" id="saveStatus"></div>
|
||||
|
||||
<div class="overall">
|
||||
<div class="overall-row">
|
||||
<div class="overall-label">Overall Progress</div>
|
||||
<div class="overall-pct" id="overallPct">0%</div>
|
||||
</div>
|
||||
<div class="bar-wrap"><div class="bar-fill" id="overallBar" style="width:0%"></div></div>
|
||||
<div class="series-stats">
|
||||
<div class="stat">Titus — <b id="titusDone">0</b> / 5 episodes done</div>
|
||||
<div class="stat">Colossians — <b id="colossDone">0</b> / 27 episodes done</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<button class="filter-btn active" onclick="setFilter('All')">All</button>
|
||||
<button class="filter-btn" onclick="setFilter('Titus')">Titus</button>
|
||||
<button class="filter-btn" onclick="setFilter('Colossians')">Colossians</button>
|
||||
</div>
|
||||
|
||||
<div id="app"><div class="loading">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Task definitions ────────────────────────────────────────────────────────
|
||||
const PRE_TASKS = [
|
||||
{ id: "verify_script", step: "01", label: "Verify Script", desc: "Review for accuracy, theology, and flow" },
|
||||
{ id: "read_script", step: "02", label: "Read Script", desc: "Read aloud, mark pacing & emphasis" },
|
||||
{ id: "record", step: "03", label: "Record", desc: "Capture final audio take" },
|
||||
{ id: "edit", step: "04", label: "Edit", desc: "Trim, cut, clean up in Ferrite / GarageBand" },
|
||||
{ id: "mix", step: "05", label: "Mix", desc: "Levels, EQ, music beds, final master" },
|
||||
{ id: "video_script", step: "06", label: "Run Video Conversion Script", desc: "Generate video file from audio" },
|
||||
{ id: "post_spotify", step: "07", label: "Post on Spotify", desc: "Upload with show notes & discussion questions" },
|
||||
];
|
||||
|
||||
const POST_TASKS = [
|
||||
{ id: "update_website", step: "08", label: "Update Website", desc: "Add episode highlights to versebyversewithnate.us" },
|
||||
{ id: "send_email", step: "09", label: "Send Email", desc: "Send newsletter to subscribers via Resend" },
|
||||
];
|
||||
|
||||
const ALL_TASK_IDS = [...PRE_TASKS, ...POST_TASKS].map(t => t.id);
|
||||
|
||||
function makeEpisode(series, number) {
|
||||
const tasks = {};
|
||||
ALL_TASK_IDS.forEach(id => tasks[id] = false);
|
||||
return { id: `${series}-${number}`, series, number, tasks, datePublished: "", expanded: false };
|
||||
}
|
||||
|
||||
function buildEpisodes() {
|
||||
const titus = [11,12,13,14,15].map(n => makeEpisode("Titus", n));
|
||||
const colossians = Array.from({ length: 27 }, (_, i) => makeEpisode("Colossians", i + 1));
|
||||
return [...titus, ...colossians];
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
let episodes = [];
|
||||
let activeFilter = "All";
|
||||
let saveTimer = null;
|
||||
|
||||
// ── Server I/O ──────────────────────────────────────────────────────────────
|
||||
async function loadFromServer() {
|
||||
try {
|
||||
const res = await fetch("/api/progress");
|
||||
const saved = await res.json();
|
||||
const base = buildEpisodes();
|
||||
episodes = base.map(ep => {
|
||||
const s = saved[ep.id];
|
||||
if (!s) return ep;
|
||||
return {
|
||||
...ep,
|
||||
expanded: s.expanded ?? false,
|
||||
datePublished: s.datePublished ?? "",
|
||||
tasks: Object.fromEntries(ALL_TASK_IDS.map(id => [id, s.tasks?.[id] ?? false]))
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
episodes = buildEpisodes();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
clearTimeout(saveTimer);
|
||||
setStatus("saving", "Saving…");
|
||||
saveTimer = setTimeout(saveToServer, 800);
|
||||
}
|
||||
|
||||
async function saveToServer() {
|
||||
const payload = {};
|
||||
episodes.forEach(ep => {
|
||||
payload[ep.id] = { expanded: ep.expanded, datePublished: ep.datePublished, tasks: ep.tasks };
|
||||
});
|
||||
try {
|
||||
await fetch("/api/progress", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
setStatus("saved", "✦ Saved");
|
||||
setTimeout(() => setStatus("", ""), 2000);
|
||||
} catch {
|
||||
setStatus("error", "Save failed — check connection");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(cls, msg) {
|
||||
const el = document.getElementById("saveStatus");
|
||||
el.className = "save-status" + (cls ? " " + cls : "");
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
function toggleTask(epId, taskId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.tasks[taskId] = !ep.tasks[taskId];
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function toggleExpand(epId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.expanded = !ep.expanded;
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function setDate(epId, val) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.datePublished = val;
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function resetEpisode(epId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ALL_TASK_IDS.forEach(id => ep.tasks[id] = false);
|
||||
ep.datePublished = "";
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
activeFilter = f;
|
||||
document.querySelectorAll(".filter-btn").forEach(btn =>
|
||||
btn.classList.toggle("active", btn.textContent === f)
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
function render() {
|
||||
updateStats();
|
||||
const app = document.getElementById("app");
|
||||
const filtered = activeFilter === "All" ? episodes : episodes.filter(e => e.series === activeFilter);
|
||||
const titus = filtered.filter(e => e.series === "Titus");
|
||||
const colossians = filtered.filter(e => e.series === "Colossians");
|
||||
|
||||
let html = "";
|
||||
if (titus.length) {
|
||||
html += `<div class="section-label">Titus — Episodes 11–15</div>`;
|
||||
titus.forEach(ep => html += renderCard(ep));
|
||||
}
|
||||
if (colossians.length) {
|
||||
html += `<div class="section-label">Colossians — Episodes 1–27</div>`;
|
||||
colossians.forEach(ep => html += renderCard(ep));
|
||||
}
|
||||
app.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatDate(val) {
|
||||
if (!val) return "";
|
||||
const [y, m, d] = val.split("-");
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
return `${months[parseInt(m)-1]} ${parseInt(d)}, ${y}`;
|
||||
}
|
||||
|
||||
function renderCard(ep) {
|
||||
const allDone = ALL_TASK_IDS.every(id => ep.tasks[id]);
|
||||
const doneCount = ALL_TASK_IDS.filter(id => ep.tasks[id]).length;
|
||||
const total = ALL_TASK_IDS.length;
|
||||
const pct = Math.round((doneCount / total) * 100);
|
||||
const dateDisplay = ep.datePublished ? formatDate(ep.datePublished) : "";
|
||||
|
||||
let body = "";
|
||||
if (ep.expanded) {
|
||||
// Pre-publish tasks
|
||||
const preTasks = PRE_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
||||
// Post-publish tasks
|
||||
const postTasks = POST_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
||||
|
||||
const footer = allDone
|
||||
? `<span class="complete-badge">✦ Complete</span>`
|
||||
: `<button class="btn-reset" onclick="event.stopPropagation();resetEpisode('${ep.id}')">Reset</button>`;
|
||||
|
||||
body = `
|
||||
<div class="card-body">
|
||||
<div class="date-row">
|
||||
<span class="date-label">Date Published</span>
|
||||
<input type="date" class="date-input" value="${ep.datePublished}"
|
||||
onchange="setDate('${ep.id}', this.value)"
|
||||
onclick="event.stopPropagation()" />
|
||||
${ep.datePublished ? `<span style="font-size:12px;color:var(--warm-gray);font-style:italic;">${formatDate(ep.datePublished)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="phase-header phase-pre">Pre-Publish</div>
|
||||
<ul class="task-list">${preTasks}</ul>
|
||||
|
||||
<div class="phase-header phase-post" style="margin-top:10px;">Post-Publish</div>
|
||||
<ul class="task-list">${postTasks}</ul>
|
||||
|
||||
<div class="card-footer">${footer}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card${allDone ? " complete" : ""}">
|
||||
<div class="card-header" onclick="toggleExpand('${ep.id}')">
|
||||
<span class="series-tag tag-${ep.series}">${ep.series}</span>
|
||||
<span class="ep-num">Ep ${ep.number}</span>
|
||||
<span class="ep-label">${ep.series} — Episode ${ep.number}</span>
|
||||
${dateDisplay ? `<span class="pub-date-pill">${dateDisplay}</span>` : ""}
|
||||
<span class="task-count">${doneCount}/${total}</span>
|
||||
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%"></div></div>
|
||||
<span class="chevron${ep.expanded ? " open" : ""}">▼</span>
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTask(epId, t, done) {
|
||||
return `
|
||||
<li class="task-item${done ? " done" : ""}" onclick="toggleTask('${epId}','${t.id}')">
|
||||
<div class="check-box">
|
||||
<svg class="check-svg" width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="#0d0d0a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="step-num">${t.step}</span>
|
||||
<div class="task-body">
|
||||
<div class="task-name">${t.label}</div>
|
||||
<div class="task-hint">${t.desc}</div>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const totalDone = episodes.reduce((a, e) => a + ALL_TASK_IDS.filter(id => e.tasks[id]).length, 0);
|
||||
const totalTasks = episodes.length * ALL_TASK_IDS.length;
|
||||
const pct = Math.round((totalDone / totalTasks) * 100);
|
||||
document.getElementById("overallPct").textContent = pct + "%";
|
||||
document.getElementById("overallBar").style.width = pct + "%";
|
||||
document.getElementById("titusDone").textContent =
|
||||
episodes.filter(e => e.series === "Titus" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
||||
document.getElementById("colossDone").textContent =
|
||||
episodes.filter(e => e.series === "Colossians" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
||||
}
|
||||
|
||||
loadFromServer();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA_FILE = process.env.DATA_FILE || "/data/progress.json";
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
// Ensure data directory and file exist
|
||||
function ensureDataFile() {
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify({}));
|
||||
}
|
||||
|
||||
// GET progress
|
||||
app.get("/api/progress", (req, res) => {
|
||||
try {
|
||||
ensureDataFile();
|
||||
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error("Read error:", err);
|
||||
res.status(500).json({ error: "Failed to read progress" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST progress (full state)
|
||||
app.post("/api/progress", (req, res) => {
|
||||
try {
|
||||
ensureDataFile();
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("Write error:", err);
|
||||
res.status(500).json({ error: "Failed to save progress" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`Podcast checklist running on port ${PORT}`));
|
||||
Binary file not shown.
+964
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
||||
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
|
||||
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
|
||||
//
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
|
||||
//
|
||||
// Needs, in your local .env (never commit it):
|
||||
// ELEVENLABS_API_KEY=...
|
||||
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
|
||||
// Optional:
|
||||
// ELEVENLABS_MODEL=eleven_multilingual_v2
|
||||
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
|
||||
// ELEVENLABS_SPEED=1
|
||||
//
|
||||
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
|
||||
// URLs into Admin → Gospel Page.
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const API = 'https://api.elevenlabs.io/v1'
|
||||
const apiKey = process.env.ELEVENLABS_API_KEY
|
||||
const voiceId = process.env.ELEVENLABS_VOICE_ID
|
||||
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
|
||||
const MAX_CHARS = 4500
|
||||
let totalCost = 0
|
||||
|
||||
function fail(message) {
|
||||
console.error(`\n${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
|
||||
|
||||
async function listVoices() {
|
||||
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
|
||||
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
|
||||
const { voices = [] } = await res.json()
|
||||
console.log('\nYour voices (name: voice id)\n')
|
||||
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
|
||||
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
|
||||
}
|
||||
|
||||
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
|
||||
function chunkText(text) {
|
||||
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
|
||||
const chunks = []
|
||||
let current = ''
|
||||
for (const p of paragraphs) {
|
||||
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
|
||||
current = current ? `${current}\n\n${p}` : p
|
||||
}
|
||||
if (current) chunks.push(current)
|
||||
return chunks
|
||||
}
|
||||
|
||||
async function speak(text, previous, next) {
|
||||
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: model,
|
||||
voice_settings: {
|
||||
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
|
||||
similarity_boost: 0.75,
|
||||
style: 0,
|
||||
use_speaker_boost: true,
|
||||
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
|
||||
},
|
||||
...(previous ? { previous_text: previous.slice(-600) } : {}),
|
||||
...(next ? { next_text: next.slice(0, 600) } : {}),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
|
||||
}
|
||||
const cost = res.headers.get('character-cost')
|
||||
if (cost) totalCost += Number(cost) || 0
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
const TRACKS = [
|
||||
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
|
||||
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
|
||||
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
|
||||
]
|
||||
|
||||
async function renderTrack(text, outPath) {
|
||||
const chunks = chunkText(text)
|
||||
const parts = []
|
||||
for (let i = 0; i < chunks.length; i += 1) {
|
||||
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
|
||||
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
|
||||
console.log('done')
|
||||
}
|
||||
await writeFile(outPath, Buffer.concat(parts))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes('--list-voices')) { await listVoices(); return }
|
||||
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
|
||||
|
||||
const outDir = path.join(ROOT, 'audio-out')
|
||||
await mkdir(outDir, { recursive: true })
|
||||
|
||||
if (process.argv.includes('--sample')) {
|
||||
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
|
||||
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
|
||||
let sample = ''
|
||||
for (const para of full.split(/\n{2,}/)) {
|
||||
if (sample && sample.length + para.length > 750) break
|
||||
sample = sample ? `${sample}\n\n${para}` : para
|
||||
}
|
||||
// Don't end on a lone heading like "The Accuser."
|
||||
const lastBreak = sample.lastIndexOf('\n\n')
|
||||
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
|
||||
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
|
||||
const out = path.join(outDir, 'salvation-sample.mp3')
|
||||
await renderTrack(sample, out)
|
||||
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
|
||||
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
|
||||
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
|
||||
|
||||
const texts = []
|
||||
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
|
||||
const chars = texts.reduce((n, t) => n + t.length, 0)
|
||||
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
|
||||
console.log('This uses about that many ElevenLabs credits.\n')
|
||||
|
||||
for (const [i, t] of tracks.entries()) {
|
||||
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
|
||||
await renderTrack(texts[i], path.join(outDir, t.out))
|
||||
}
|
||||
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
|
||||
}
|
||||
|
||||
main().catch(err => fail(err?.message ?? String(err)))
|
||||
@@ -0,0 +1,71 @@
|
||||
The Gospel, in a Courtroom.
|
||||
|
||||
Imagine a courtroom. Not a courtroom on earth, but the highest court there is: the Court of Heaven. God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But before the verdict is read, a Substitute has stepped forward to take your place. Let me walk you through the case.
|
||||
|
||||
The Judge.
|
||||
|
||||
God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. In the book of Genesis, chapter eighteen, verse twenty-five, Scripture asks: "Will not the Judge of all the earth do right?" And in Acts, chapter seventeen, verse thirty-one: "For He has set a day when He will judge the world with justice by the Man He has appointed."
|
||||
|
||||
The Accuser.
|
||||
|
||||
Every courtroom has an accuser, and ours is Satan. His very name means "adversary." Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. Revelation, chapter twelve, verse ten, calls him "the accuser of our brothers, who accuses them before our God day and night." And First Peter, chapter five, verse eight, warns: "Your adversary the devil prowls around like a roaring lion, looking for someone to devour."
|
||||
|
||||
The Charges.
|
||||
|
||||
So what are the charges? The charges are the sins you have committed.
|
||||
|
||||
Sin is breaking God's law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret.
|
||||
|
||||
It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God's standard is written on every conscience, so that no one is without excuse. Even sin done unknowingly still leaves a person guilty.
|
||||
|
||||
And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you, and it includes me.
|
||||
|
||||
First John, chapter three, verse four: "Everyone who practices sin practices lawlessness; indeed, sin is lawlessness." James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." Romans, chapter three, verse ten: "There is no one righteous, not even one." And Romans, chapter three, verse twenty-three: "For all have sinned and fall short of the glory of God."
|
||||
|
||||
The Verdict.
|
||||
|
||||
The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death: separation from the God who made us. Romans, chapter three, verse nineteen: "...so that every mouth may be silenced and the whole world held accountable to God." And Romans, chapter six, verse twenty-three: "For the wages of sin is death..."
|
||||
|
||||
But that is not the end of the story.
|
||||
|
||||
The Substitute.
|
||||
|
||||
Then God himself stepped forward to take your place.
|
||||
|
||||
Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading.
|
||||
|
||||
He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted.
|
||||
|
||||
John, chapter one, verse fourteen: "The Word became flesh and dwelt among us." Matthew, chapter one, verse twenty-one: "She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins." Mark, chapter ten, verse forty-five: "For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many." First Peter, chapter three, verse eighteen: "For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God." Second Corinthians, chapter five, verse twenty-one: "God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God." And Romans, chapter five, verse eight: "But God proves His love for us in this: While we were still sinners, Christ died for us."
|
||||
|
||||
The Pardon.
|
||||
|
||||
The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done.
|
||||
|
||||
Ephesians, chapter two, verses eight and nine: "For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast." And Romans, chapter ten, verse nine: "If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved."
|
||||
|
||||
The Result.
|
||||
|
||||
Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor.
|
||||
|
||||
Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." And verses thirty-three and thirty-four: "Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died, more than that, was raised to life, is at the right hand of God, and He is interceding for us."
|
||||
|
||||
Before you plead.
|
||||
|
||||
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
|
||||
|
||||
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
|
||||
|
||||
How do you plead?
|
||||
|
||||
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
|
||||
|
||||
You have two choices.
|
||||
|
||||
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
|
||||
|
||||
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
|
||||
|
||||
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
|
||||
|
||||
Take a moment. Be honest with God, and honest with yourself.
|
||||
@@ -0,0 +1,19 @@
|
||||
You have pleaded guilty. That is the beginning of the good news.
|
||||
|
||||
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
|
||||
|
||||
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
|
||||
|
||||
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
|
||||
|
||||
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
|
||||
|
||||
Third, tell someone. Tell a Christian friend or family member what you decided.
|
||||
|
||||
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
|
||||
|
||||
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
|
||||
|
||||
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
|
||||
|
||||
Welcome to the family of God.
|
||||
@@ -0,0 +1,23 @@
|
||||
You have pleaded not guilty. Please be sure you understand what that means.
|
||||
|
||||
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
|
||||
|
||||
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
|
||||
|
||||
So test your plea honestly.
|
||||
|
||||
Have you ever told a lie, even a small one?
|
||||
|
||||
Have you ever taken something that was not yours?
|
||||
|
||||
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
|
||||
|
||||
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
|
||||
|
||||
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
|
||||
|
||||
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
|
||||
|
||||
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
|
||||
|
||||
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
loadDownloadCountsFromDisk,
|
||||
loadQrCodesFromDisk,
|
||||
loadEpisodePlaysFromDisk,
|
||||
loadPodcastChecklistFromDisk,
|
||||
loadAnalyticsEventsFromDisk,
|
||||
loadEmailSettingsFromDisk,
|
||||
loadCalendarEventsFromDisk,
|
||||
loadAuditLogFromDisk,
|
||||
loadWebhooksFromDisk,
|
||||
loadStudySessionsFromDisk,
|
||||
queueStudySessionsWrite,
|
||||
loadNewsletterIssuesFromDisk,
|
||||
loadNuggetsTagsFromDisk,
|
||||
createBackupSnapshot,
|
||||
refreshContentCaches,
|
||||
queueHitStatsWrite,
|
||||
@@ -37,17 +39,21 @@ import {
|
||||
sanitizeUserAgent,
|
||||
shouldCountHit,
|
||||
recordHit,
|
||||
isKnownSitePath,
|
||||
scheduleStudyReminders,
|
||||
scheduleReengagementEmails,
|
||||
scheduleWeeklyDigestEmails,
|
||||
backfillCompletedStudies,
|
||||
backfillReminderDefault,
|
||||
} from './server/study-helpers.js'
|
||||
import { recordVisitor } from './server/routes/analytics.js'
|
||||
|
||||
// Route registrars
|
||||
import { register as registerAdminAuth } from './server/routes/admin-auth.js'
|
||||
import { register as registerAdminContent } from './server/routes/admin-content.js'
|
||||
import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
|
||||
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
|
||||
import { register as registerAdminBackup } from './server/routes/admin-backup.js'
|
||||
import { register as registerAdminDashboard } from './server/routes/admin-dashboard.js'
|
||||
import { register as registerStudyAuth } from './server/routes/study-auth.js'
|
||||
import { register as registerStudyData } from './server/routes/study-data.js'
|
||||
import { register as registerStudyAccount } from './server/routes/study-account.js'
|
||||
@@ -55,19 +61,28 @@ import { register as registerStudyComments } from './server/routes/study-comment
|
||||
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||
import { register as registerContact } from './server/routes/contact.js'
|
||||
import { register as registerCalendarEvents } from './server/routes/calendar-events.js'
|
||||
import { startReminderScheduler } from './server/reminder.js'
|
||||
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
|
||||
import { register as registerQuestions } from './server/routes/questions.js'
|
||||
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||
import { register as registerDownloads } from './server/routes/downloads.js'
|
||||
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||
import { register as registerSeasonCheck } from './server/season-check.js'
|
||||
import { warmFeedSnapshot } from './server/feed-release.js'
|
||||
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||
import { register as registerFeed } from './server/routes/feed.js'
|
||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||
import { register as registerNewsletter } from './server/routes/newsletter.js'
|
||||
import { register as registerSalvation } from './server/routes/salvation.js'
|
||||
import { register as registerPublic } from './server/routes/public.js'
|
||||
|
||||
preferIpv4ForSeafile()
|
||||
|
||||
const app = express()
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
|
||||
@@ -77,16 +92,20 @@ app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000')
|
||||
}
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com https://storage.ko-fi.com https://static.cloudflareinsights.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.truthforlife.org",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: https:",
|
||||
"media-src 'self' https:",
|
||||
"frame-src https:",
|
||||
"frame-src 'self' https:",
|
||||
"connect-src 'self' https:",
|
||||
].join('; '),
|
||||
)
|
||||
@@ -98,6 +117,7 @@ registerAdminAuth(app)
|
||||
registerAdminContent(app)
|
||||
registerAdminAssets(app)
|
||||
registerAdminBackup(app)
|
||||
registerAdminDashboard(app)
|
||||
registerStudyAuth(app)
|
||||
registerStudyData(app)
|
||||
registerStudyAccount(app)
|
||||
@@ -105,28 +125,36 @@ registerStudyComments(app)
|
||||
registerStudyCertificate(app)
|
||||
registerEpisodeScripts(app)
|
||||
registerContact(app)
|
||||
registerCalendarEvents(app)
|
||||
registerInboundEmail(app)
|
||||
registerQuestions(app)
|
||||
registerAnalytics(app)
|
||||
registerDownloads(app)
|
||||
registerEpisodes(app)
|
||||
registerNuggets(app)
|
||||
registerNuggetsHostUpload(app)
|
||||
registerNuggetsWorkflow(app)
|
||||
registerPodcastWorkflow(app)
|
||||
registerSeasonCheck(app)
|
||||
registerFeed(app)
|
||||
registerQrCodes(app)
|
||||
registerBiblePassage(app)
|
||||
registerNewsletter(app)
|
||||
registerSalvation(app)
|
||||
|
||||
// Hit-counting middleware (must come before public routes)
|
||||
app.use((req, res, next) => {
|
||||
if (shouldCountHit(req)) {
|
||||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||
const botDetection = detectBot(ua)
|
||||
const knownPath = isKnownSitePath(req.path)
|
||||
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
|
||||
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||||
queueHitStatsWrite()
|
||||
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
|
||||
recordVisitor(req, res).catch(err => {
|
||||
try {
|
||||
recordVisitor(req, res)
|
||||
} catch (err) {
|
||||
console.error('[visitor-stats] failed to record visitor:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -135,6 +163,18 @@ app.use((req, res, next) => {
|
||||
// Public routes (robots, sitemap, static, SPA fallback)
|
||||
registerPublic(app)
|
||||
|
||||
// JSON error responses for API routes instead of Express's default HTML page.
|
||||
app.use((err, req, res, _next) => {
|
||||
if (res.headersSent) return
|
||||
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500
|
||||
if (status >= 500) console.error('[error]', req.method, req.path, err)
|
||||
if (req.path.startsWith('/api/')) {
|
||||
res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') })
|
||||
return
|
||||
}
|
||||
res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.')
|
||||
})
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 4173)
|
||||
Promise.all([
|
||||
loadHitStatsFromDisk(),
|
||||
@@ -154,31 +194,45 @@ Promise.all([
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadQrCodesFromDisk(),
|
||||
loadEpisodePlaysFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
loadAnalyticsEventsFromDisk(),
|
||||
loadEmailSettingsFromDisk(),
|
||||
loadCalendarEventsFromDisk(),
|
||||
loadAuditLogFromDisk(),
|
||||
loadWebhooksFromDisk(),
|
||||
loadStudySessionsFromDisk(),
|
||||
loadNewsletterIssuesFromDisk(),
|
||||
loadNuggetsTagsFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
.catch(err => {
|
||||
console.error('[stats] failed to load persisted stats:', err)
|
||||
})
|
||||
.then(() => backfillCompletedStudies())
|
||||
.then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) })
|
||||
.catch(err => {
|
||||
console.error('[study] completed-study backfill failed:', err)
|
||||
})
|
||||
.then(() => backfillReminderDefault())
|
||||
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
|
||||
.catch(err => {
|
||||
console.error('[study] reminder-default backfill failed:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
createBackupSnapshot('startup').catch(() => {})
|
||||
setInterval(() => {
|
||||
createBackupSnapshot('scheduled').catch(() => {})
|
||||
}, BACKUP_INTERVAL_MS)
|
||||
|
||||
startReminderScheduler()
|
||||
startNuggetsSeafileWatcher()
|
||||
warmFeedSnapshot()
|
||||
|
||||
// Purge expired study sessions every hour
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
let pruned = false
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.expiresAt <= now) state.studySessions.delete(token)
|
||||
if (session.expiresAt <= now) { state.studySessions.delete(token); pruned = true }
|
||||
}
|
||||
if (pruned) queueStudySessionsWrite()
|
||||
}, 60 * 60 * 1000)
|
||||
|
||||
// Send study reminder emails for newly released lessons every hour
|
||||
@@ -187,7 +241,9 @@ Promise.all([
|
||||
console.error('[study-reminders] failed to schedule reminders:', err)
|
||||
})
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleStudyReminders(sendStudyReminderEmail)
|
||||
scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
|
||||
console.error('[study-reminders] failed to schedule reminders:', err)
|
||||
})
|
||||
|
||||
// Send re-engagement emails to inactive study users every hour
|
||||
setInterval(() => {
|
||||
@@ -195,7 +251,9 @@ Promise.all([
|
||||
console.error('[study-reengagement] failed to schedule emails:', err)
|
||||
})
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleReengagementEmails(sendStudyReengagementEmail)
|
||||
scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
|
||||
console.error('[study-reengagement] failed to schedule emails:', err)
|
||||
})
|
||||
|
||||
// Send weekly lesson digest emails every hour (only actually fires every 7 days per user)
|
||||
setInterval(() => {
|
||||
@@ -203,10 +261,79 @@ Promise.all([
|
||||
console.error('[study-digest] failed to schedule weekly digest:', err)
|
||||
})
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
|
||||
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
|
||||
console.error('[study-digest] failed to schedule weekly digest:', err)
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
// Check for a scheduled series transition every minute
|
||||
setInterval(() => {
|
||||
runScheduledSeriesTransition().catch(err => {
|
||||
console.error('[series-scheduler] error:', err)
|
||||
})
|
||||
}, 60 * 1000)
|
||||
runScheduledSeriesTransition().catch(err => {
|
||||
console.error('[series-scheduler] error:', err)
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logResendEmailAlignmentWarnings()
|
||||
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
||||
})
|
||||
|
||||
// On every deploy the running container gets SIGTERM'd and replaced.
|
||||
// All the queueXWrite() helpers in data.js debounce disk writes onto
|
||||
// per-resource promise chains — without draining those here, a redeploy
|
||||
// can kill the process mid-write and silently lose the last few
|
||||
// analytics events (or any other pending write) of that container's life.
|
||||
function collectPendingWrites() {
|
||||
return [
|
||||
state.hitStatsWritePromise,
|
||||
state.visitorStatsWritePromise,
|
||||
state.contactSubmissionsWritePromise,
|
||||
state.questionsWritePromise,
|
||||
state.draftQuestionsWritePromise,
|
||||
state.replyTemplatesWritePromise,
|
||||
state.replyHistoryWritePromise,
|
||||
state.studyUsersWritePromise,
|
||||
state.studyCommunityWritePromise,
|
||||
state.studyRemindersWritePromise,
|
||||
state.studyCommentsWritePromise,
|
||||
state.studyCertificatesWritePromise,
|
||||
state.episodeScriptsWritePromise,
|
||||
state.downloadCountsWritePromise,
|
||||
state.episodePlaysWritePromise,
|
||||
state.analyticsEventsWritePromise,
|
||||
state.qrCodesWritePromise,
|
||||
state.emailSettingsWritePromise,
|
||||
state.auditLogWritePromise,
|
||||
state.webhooksWritePromise,
|
||||
state.studySessionsWritePromise,
|
||||
...state.studyNotesWriteQueues.values(),
|
||||
...state.studyProgressWriteQueues.values(),
|
||||
]
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
async function gracefulShutdown(signal) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
console.log(`[shutdown] ${signal} received — closing server and flushing pending writes...`)
|
||||
server.close()
|
||||
const forceExit = setTimeout(() => {
|
||||
console.error('[shutdown] timed out waiting for pending writes; exiting anyway')
|
||||
process.exit(1)
|
||||
}, 10_000)
|
||||
try {
|
||||
await Promise.all(collectPendingWrites())
|
||||
console.log('[shutdown] all pending writes flushed')
|
||||
} catch (err) {
|
||||
console.error('[shutdown] error while flushing writes:', err)
|
||||
} finally {
|
||||
clearTimeout(forceExit)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { writeFile as fsWriteFile, rename, unlink } from 'node:fs/promises'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
// Drop-in replacement for fs.promises.writeFile that never leaves a truncated
|
||||
// file behind: the data goes to a sibling temp file which is then renamed over
|
||||
// the target (atomic on the same filesystem). A crash mid-write leaves the
|
||||
// previous version intact instead of a half-written JSON file.
|
||||
export async function writeFile(file, data, options) {
|
||||
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
||||
try {
|
||||
await fsWriteFile(tmp, data, options)
|
||||
await rename(tmp, file)
|
||||
} catch (err) {
|
||||
await unlink(tmp).catch(() => {})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
+44
-11
@@ -1,5 +1,7 @@
|
||||
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { writeFile } from './atomic-write.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { parseCookies, cookieSecureFlag } from './helpers.js'
|
||||
import { DATA_DIR } from './paths.js'
|
||||
@@ -11,16 +13,44 @@ const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
|
||||
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||
const totpPendingSessions = new Map()
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
export const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||
const adminSessions = new Map()
|
||||
|
||||
function cookieFlags() {
|
||||
return cookieSecureFlag()
|
||||
// Sessions survive restarts/deploys. Only a SHA-256 of each token is written to
|
||||
// disk, so the file alone cannot be used to hijack a session. Sliding-expiry
|
||||
// refreshes are not persisted; a restart reverts to the last persisted expiry.
|
||||
const ADMIN_SESSIONS_FILE = path.join(DATA_DIR, 'admin-sessions.json')
|
||||
let adminSessionsWrite = Promise.resolve()
|
||||
|
||||
function persistAdminSessions() {
|
||||
const sessions = []
|
||||
for (const [tokenHash, session] of adminSessions) {
|
||||
if (session && typeof session === 'object') sessions.push({ tokenHash, ...session })
|
||||
}
|
||||
adminSessionsWrite = adminSessionsWrite
|
||||
.then(() => writeFile(ADMIN_SESSIONS_FILE, JSON.stringify({ sessions }), { encoding: 'utf8', mode: 0o600 }))
|
||||
.catch(err => { console.error('[admin-sessions] write failed:', err) })
|
||||
}
|
||||
|
||||
// Loaded sessions are keyed by token hash; lookups hash the cookie value, so the
|
||||
// in-memory map is keyed by hash throughout.
|
||||
function adminSessionKey(token) {
|
||||
return sha256(token)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(ADMIN_SESSIONS_FILE, 'utf8'))
|
||||
const now = Date.now()
|
||||
for (const e of Array.isArray(parsed?.sessions) ? parsed.sessions : []) {
|
||||
if (typeof e?.tokenHash !== 'string' || typeof e.expiresAt !== 'number' || typeof e.absoluteExpiresAt !== 'number') continue
|
||||
if (e.expiresAt <= now || e.absoluteExpiresAt <= now) continue
|
||||
adminSessions.set(e.tokenHash, { expiresAt: e.expiresAt, absoluteExpiresAt: e.absoluteExpiresAt })
|
||||
}
|
||||
} catch { /* no persisted sessions */ }
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(String(value)).digest('hex')
|
||||
}
|
||||
@@ -217,13 +247,15 @@ export function consumePendingSession(token) {
|
||||
export function createAdminSession() {
|
||||
const token = randomUUID()
|
||||
const now = Date.now()
|
||||
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
|
||||
adminSessions.set(adminSessionKey(token), { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
|
||||
persistAdminSessions()
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteAdminSession(token) {
|
||||
if (token) {
|
||||
adminSessions.delete(token)
|
||||
adminSessions.delete(adminSessionKey(token))
|
||||
persistAdminSessions()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +267,8 @@ export function isValidAdminSession(req) {
|
||||
if (!sessionToken) return false
|
||||
|
||||
const now = Date.now()
|
||||
const session = adminSessions.get(sessionToken)
|
||||
const key = adminSessionKey(sessionToken)
|
||||
const session = adminSessions.get(key)
|
||||
if (!session) return false
|
||||
|
||||
// Support legacy sessions stored as a plain number (expiresAt)
|
||||
@@ -243,25 +276,25 @@ export function isValidAdminSession(req) {
|
||||
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
|
||||
|
||||
if (expiresAt <= now || absoluteExpiresAt <= now) {
|
||||
adminSessions.delete(sessionToken)
|
||||
adminSessions.delete(key)
|
||||
return false
|
||||
}
|
||||
|
||||
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
|
||||
adminSessions.set(key, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
|
||||
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()}`,
|
||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+36
-95
@@ -5,6 +5,7 @@ import { validateAdminPasswordSetup } from './auth.js'
|
||||
import { ROOT_DIR, DATA_DIR } from './paths.js'
|
||||
|
||||
export { ROOT_DIR, DATA_DIR }
|
||||
export { DEFAULT_SEO, DEFAULT_LEGAL, DEFAULT_REDIRECT_RULES, DEFAULT_PODCAST_FEATURED_LINKS, DEFAULT_PUBLISH_STATE } from './helpers.js'
|
||||
|
||||
function readPackageVersion() {
|
||||
try {
|
||||
@@ -46,7 +47,6 @@ export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
|
||||
export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
|
||||
export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||
export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||
export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||
export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
||||
@@ -58,12 +58,41 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
||||
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
||||
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
||||
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
||||
export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json')
|
||||
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
|
||||
export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
|
||||
export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
|
||||
export const NUGGETS_PORTAL_GUIDELINES_FILE = path.join(DATA_DIR, 'nuggets-portal-guidelines.json')
|
||||
export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
|
||||
|
||||
// Email address for Nuggets upload notifications (defaults to the admin inbox)
|
||||
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
|
||||
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
|
||||
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
|
||||
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
||||
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
|
||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||
|
||||
// Seafile integration for Nuggets episode uploads
|
||||
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
|
||||
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
|
||||
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
|
||||
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
|
||||
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
|
||||
|
||||
// Cloudflare R2 storage
|
||||
export const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID ?? ''
|
||||
export const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID ?? ''
|
||||
export const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY ?? ''
|
||||
export const R2_BUCKET = process.env.R2_BUCKET ?? ''
|
||||
export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? '').replace(/\/$/, '')
|
||||
|
||||
export const HOST_SESSION_COOKIE = 'vbn_host_session'
|
||||
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
||||
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
||||
@@ -83,7 +112,6 @@ export const MAX_RECENT_VISITS = 1000
|
||||
export const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
||||
export const BACKUP_RETENTION_DAYS = 30
|
||||
export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
export const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export const MAX_CONTACT_SUBMISSIONS = 5000
|
||||
export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
|
||||
@@ -94,9 +122,11 @@ export const MAX_STUDY_COMMENTS = 10000
|
||||
export const MAX_STUDY_COMMENT_LENGTH = 2000
|
||||
export const MAX_STUDY_USERS = 5000
|
||||
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||
export const MAX_STUDY_NOTES_PER_USER = 500
|
||||
export const MAX_STUDY_NOTE_LENGTH = 12000
|
||||
export const MAX_STUDY_NOTES_PER_USER = 3000
|
||||
export const MAX_STUDY_NOTE_LENGTH = 200000
|
||||
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||||
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
|
||||
export const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||||
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||
@@ -112,58 +142,6 @@ export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewith
|
||||
// Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx.
|
||||
export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM]
|
||||
|
||||
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 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_REPLY_TEMPLATES = [
|
||||
{
|
||||
id: 'thanks-for-reaching-out',
|
||||
@@ -185,18 +163,6 @@ export const DEFAULT_REPLY_TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_PODCAST_CHECKLIST_TASKS = [
|
||||
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
|
||||
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
|
||||
{ id: 'record', label: 'Record', phase: 'pre' },
|
||||
{ id: 'mix', label: 'Mix', phase: 'pre' },
|
||||
{ id: 'edit', label: 'Edit', phase: 'pre' },
|
||||
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
|
||||
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
|
||||
{ id: 'update_website', label: 'Update Website', phase: 'post' },
|
||||
{ id: 'send_email', label: 'Send Email', phase: 'post' },
|
||||
]
|
||||
|
||||
export const EMPTY_HIT_STATS = {
|
||||
totalHits: 0,
|
||||
realHits: 0,
|
||||
@@ -206,6 +172,7 @@ export const EMPTY_HIT_STATS = {
|
||||
byPath: {},
|
||||
byPathReal: {},
|
||||
byPathBot: {},
|
||||
byPathUnknown: {},
|
||||
byDay: {},
|
||||
byDayReal: {},
|
||||
byDayBot: {},
|
||||
@@ -224,29 +191,3 @@ export const EMPTY_VISITOR_STATS = {
|
||||
geoCacheByIp: {},
|
||||
}
|
||||
|
||||
// ── Podcast checklist helpers (no state dependency) ────────────────────────
|
||||
|
||||
function buildChecklistEpisode(series, number) {
|
||||
const tasks = {}
|
||||
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
|
||||
tasks[task.id] = false
|
||||
}
|
||||
return {
|
||||
id: `${series.toLowerCase()}-${number}`,
|
||||
series,
|
||||
episodeNumber: number,
|
||||
title: '',
|
||||
datePublished: '',
|
||||
expanded: false,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultPodcastChecklist() {
|
||||
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
|
||||
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
|
||||
return {
|
||||
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
|
||||
episodes: [...titusEpisodes, ...colossiansEpisodes],
|
||||
}
|
||||
}
|
||||
+197
-145
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { migrateScriptKeys } from './episode-script-keys.js'
|
||||
import { mkdir, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { writeFile } from './atomic-write.js'
|
||||
import { assertPublicHttpsUrl } from './url-safety.js'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { sanitizeSiteContent } from './helpers.js'
|
||||
@@ -23,7 +26,6 @@ import {
|
||||
QR_CODES_FILE,
|
||||
REPLY_TEMPLATES_FILE,
|
||||
REPLY_HISTORY_FILE,
|
||||
PODCAST_CHECKLIST_FILE,
|
||||
BACKUP_DIR,
|
||||
UPLOADS_DIR,
|
||||
UPLOADS_META_FILE,
|
||||
@@ -31,9 +33,11 @@ import {
|
||||
EPISODE_PLAYS_FILE,
|
||||
ANALYTICS_EVENTS_FILE,
|
||||
EMAIL_SETTINGS_FILE,
|
||||
CALENDAR_EVENTS_FILE,
|
||||
AUDIT_LOG_FILE,
|
||||
WEBHOOKS_FILE,
|
||||
STUDY_SESSIONS_FILE,
|
||||
NEWSLETTER_ISSUES_FILE,
|
||||
NUGGETS_TAGS_FILE,
|
||||
EMPTY_HIT_STATS,
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
@@ -46,8 +50,6 @@ import {
|
||||
MAX_STUDY_NOTES_PER_USER,
|
||||
MAX_STUDY_NOTE_LENGTH,
|
||||
MAX_STUDY_COMMENTS,
|
||||
DEFAULT_PODCAST_CHECKLIST_TASKS,
|
||||
buildDefaultPodcastChecklist,
|
||||
} from './config.js'
|
||||
import { state } from './state.js'
|
||||
|
||||
@@ -165,6 +167,7 @@ export function loadHitStatsFromDisk() {
|
||||
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
||||
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
|
||||
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
|
||||
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
|
||||
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
||||
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
|
||||
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
|
||||
@@ -419,36 +422,6 @@ export function loadEmailSettingsFromDisk() {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Calendar events ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueCalendarEventsWrite() {
|
||||
state.calendarEventsWritePromise = state.calendarEventsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
CALENDAR_EVENTS_FILE,
|
||||
JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[calendar-events] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadCalendarEventsFromDisk() {
|
||||
return readFile(CALENDAR_EVENTS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed?.events)) {
|
||||
state.calendarEvents = parsed.events
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// No file yet — start with empty array
|
||||
})
|
||||
}
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function appendAuditEntry(action, details) {
|
||||
@@ -496,6 +469,33 @@ export function loadWebhooksFromDisk() {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ── Study sessions ──────────────────────────────────────────────────────
|
||||
|
||||
export function queueStudySessionsWrite() {
|
||||
state.studySessionsWritePromise = state.studySessionsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
const sessions = Array.from(state.studySessions.entries()).map(([token, session]) => ({ token, ...session }))
|
||||
await writeFile(STUDY_SESSIONS_FILE, JSON.stringify({ sessions }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => { console.error('[study-sessions] write failed:', err) })
|
||||
}
|
||||
|
||||
export function loadStudySessionsFromDisk() {
|
||||
return readFile(STUDY_SESSIONS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed?.sessions)) return
|
||||
const now = Date.now()
|
||||
for (const entry of parsed.sessions) {
|
||||
if (!entry?.token || !entry?.userId || typeof entry.expiresAt !== 'number') continue
|
||||
if (entry.expiresAt <= now) continue
|
||||
state.studySessions.set(entry.token, { userId: entry.userId, expiresAt: entry.expiresAt })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
export async function fireWebhooks(eventType, payload) {
|
||||
const targets = state.webhooks.filter(w =>
|
||||
Array.isArray(w.events) && (w.events.includes(eventType) || w.events.includes('*'))
|
||||
@@ -503,12 +503,14 @@ export async function fireWebhooks(eventType, payload) {
|
||||
if (targets.length === 0) return
|
||||
const body = JSON.stringify({ event: eventType, data: payload, firedAt: new Date().toISOString() })
|
||||
for (const wh of targets) {
|
||||
fetch(wh.url, {
|
||||
// Re-check at delivery time: DNS may have changed since the webhook was registered.
|
||||
assertPublicHttpsUrl(wh.url).then(() => fetch(wh.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'Siteforge-Webhook/1.0' },
|
||||
body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
}))
|
||||
.then(r => {
|
||||
appendAuditEntry('webhook-fired', `${eventType} → ${wh.url} (${r.status})`)
|
||||
})
|
||||
@@ -518,32 +520,6 @@ export async function fireWebhooks(eventType, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Podcast checklist ──────────────────────────────────────────────────────
|
||||
|
||||
export function queuePodcastChecklistWrite() {
|
||||
const updatedAt = new Date().toISOString()
|
||||
state.podcastChecklistWritePromise = state.podcastChecklistWritePromise.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
PODCAST_CHECKLIST_FILE,
|
||||
JSON.stringify({ checklist: state.podcastChecklist, updatedAt }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
return state.podcastChecklistWritePromise
|
||||
}
|
||||
|
||||
export function loadPodcastChecklistFromDisk() {
|
||||
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
|
||||
})
|
||||
.catch(() => {
|
||||
state.podcastChecklist = buildDefaultPodcastChecklist()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Study users ────────────────────────────────────────────────────────────
|
||||
|
||||
export function queueStudyUsersWrite() {
|
||||
@@ -775,7 +751,11 @@ export async function loadEpisodeScriptsFromDisk() {
|
||||
try {
|
||||
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
|
||||
const loaded = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
|
||||
const { scripts, changed } = migrateScriptKeys(loaded)
|
||||
state.episodeScripts = scripts
|
||||
// Persist the move to season-episode keys once, so it isn't redone on every start
|
||||
if (changed) queueEpisodeScriptsWrite()
|
||||
} catch {
|
||||
state.episodeScripts = {}
|
||||
}
|
||||
@@ -959,23 +939,68 @@ export async function writeUploadsMetadata(metadata) {
|
||||
}
|
||||
|
||||
export async function listUploadedAssets() {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
const files = await readdir(UPLOADS_DIR)
|
||||
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
|
||||
const metadata = await readUploadsMetadata()
|
||||
|
||||
const withStats = await Promise.all(imageFiles.map(async filename => {
|
||||
const info = await stat(path.join(UPLOADS_DIR, filename))
|
||||
// R2-backed assets (new uploads)
|
||||
let r2Assets = []
|
||||
try {
|
||||
const { isR2Configured, listR2Objects } = await import('./r2.js')
|
||||
const { R2_PUBLIC_URL } = await import('./config.js')
|
||||
if (isR2Configured()) {
|
||||
const objects = await listR2Objects('')
|
||||
r2Assets = objects
|
||||
.filter(obj => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(obj.Key))
|
||||
.map(obj => {
|
||||
const parts = obj.Key.split('/')
|
||||
const filename = parts.pop()
|
||||
const folder = parts.length > 0 ? parts[0] : ''
|
||||
const entry = metadata[filename]
|
||||
const tags = Array.isArray(entry)
|
||||
? entry.filter(t => typeof t === 'string')
|
||||
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
|
||||
return {
|
||||
filename,
|
||||
key: obj.Key,
|
||||
storage: 'r2',
|
||||
url: `${R2_PUBLIC_URL}/${obj.Key}`,
|
||||
folder,
|
||||
sizeBytes: obj.Size ?? 0,
|
||||
updatedAt: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
|
||||
tags,
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch { /* R2 not available */ }
|
||||
|
||||
// Local files not already in R2 metadata
|
||||
const r2Filenames = new Set(r2Assets.map(a => a.filename))
|
||||
let localAssets = []
|
||||
try {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
const files = await readdir(UPLOADS_DIR)
|
||||
const localFiles = files
|
||||
.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name) && !r2Filenames.has(name))
|
||||
.sort()
|
||||
localAssets = await Promise.all(localFiles.map(async filename => {
|
||||
const info = await stat(path.join(UPLOADS_DIR, filename))
|
||||
const entry = metadata[filename]
|
||||
const tags = Array.isArray(entry)
|
||||
? entry.filter(t => typeof t === 'string')
|
||||
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
|
||||
return {
|
||||
filename,
|
||||
key: filename,
|
||||
storage: 'local',
|
||||
url: `/uploads/${filename}`,
|
||||
folder: 'local',
|
||||
sizeBytes: info.size,
|
||||
updatedAt: info.mtime.toISOString(),
|
||||
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
|
||||
tags,
|
||||
}
|
||||
}))
|
||||
} catch { /* no local uploads dir */ }
|
||||
|
||||
return withStats
|
||||
return [...r2Assets, ...localAssets].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
|
||||
}
|
||||
|
||||
// ── Backups ────────────────────────────────────────────────────────────────
|
||||
@@ -991,10 +1016,11 @@ export async function createBackupSnapshot(reason = 'scheduled') {
|
||||
reason,
|
||||
adminContent: null,
|
||||
draftContent: null,
|
||||
podcastChecklist: state.podcastChecklist,
|
||||
publishState: state.publishState,
|
||||
hitStats: state.hitStats,
|
||||
visitorStats: state.visitorStats,
|
||||
analyticsEvents: state.analyticsEvents,
|
||||
episodePlays: state.episodePlays,
|
||||
contactSubmissions: state.contactSubmissions,
|
||||
studyCommunityPosts: state.studyCommunityPosts,
|
||||
replyTemplates: state.replyTemplates,
|
||||
@@ -1110,25 +1136,28 @@ export async function restoreFromBackup(filename) {
|
||||
|
||||
state.hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
|
||||
state.visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
|
||||
state.analyticsEvents = sanitizeLoadedAnalyticsEvents(parsed?.analyticsEvents)
|
||||
state.episodePlays = sanitizeLoadedEpisodePlays(parsed?.episodePlays)
|
||||
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||||
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
||||
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
||||
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
|
||||
|
||||
queueHitStatsWrite()
|
||||
queueVisitorStatsWrite()
|
||||
queueAnalyticsEventsWrite()
|
||||
queueEpisodePlaysWrite()
|
||||
queueContactSubmissionsWrite()
|
||||
queueReplyTemplatesWrite()
|
||||
queueReplyHistoryWrite()
|
||||
queuePodcastChecklistWrite()
|
||||
|
||||
await Promise.all([
|
||||
state.hitStatsWritePromise,
|
||||
state.visitorStatsWritePromise,
|
||||
state.analyticsEventsWritePromise,
|
||||
state.episodePlaysWritePromise,
|
||||
state.contactSubmissionsWritePromise,
|
||||
state.replyTemplatesWritePromise,
|
||||
state.replyHistoryWritePromise,
|
||||
state.podcastChecklistWritePromise,
|
||||
])
|
||||
await refreshContentCaches()
|
||||
await createBackupSnapshot('post-restore')
|
||||
@@ -1138,7 +1167,7 @@ export async function restoreFromBackup(filename) {
|
||||
// (These are defined here rather than study-helpers to avoid circular imports)
|
||||
|
||||
export function normalizeMessageType(value) {
|
||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
||||
if (value === 'question' || value === 'testimony' || value === 'topic' || value === 'gospel' || value === 'community') return value
|
||||
return 'general'
|
||||
}
|
||||
|
||||
@@ -1222,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
|
||||
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
|
||||
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
|
||||
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
|
||||
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
|
||||
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
|
||||
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
|
||||
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
|
||||
@@ -1229,6 +1259,23 @@ function sanitizeLoadedHitStats(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLoadedAnalyticsEvents(value) {
|
||||
return {
|
||||
scrollDepth: value?.scrollDepth && typeof value.scrollDepth === 'object' ? value.scrollDepth : {},
|
||||
timeOnPage: value?.timeOnPage && typeof value.timeOnPage === 'object' ? value.timeOnPage : {},
|
||||
outboundClicks: value?.outboundClicks && typeof value.outboundClicks === 'object' ? value.outboundClicks : {},
|
||||
linkClicks: value?.linkClicks && typeof value.linkClicks === 'object' ? value.linkClicks : {},
|
||||
utmSources: value?.utmSources && typeof value.utmSources === 'object' ? value.utmSources : {},
|
||||
searchQueries: value?.searchQueries && typeof value.searchQueries === 'object' ? value.searchQueries : {},
|
||||
notFound: value?.notFound && typeof value.notFound === 'object' ? value.notFound : {},
|
||||
audioEvents: value?.audioEvents && typeof value.audioEvents === 'object' ? value.audioEvents : {},
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLoadedEpisodePlays(value) {
|
||||
return (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}
|
||||
}
|
||||
|
||||
function sanitizeLoadedVisitorStats(value) {
|
||||
const loadedVisitors = value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}
|
||||
|
||||
@@ -1285,75 +1332,6 @@ export function sanitizeReplyHistory(value) {
|
||||
}))
|
||||
}
|
||||
|
||||
function sanitizeChecklistTask(task) {
|
||||
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
|
||||
if (!label) return null
|
||||
const phase = task?.phase === 'post' ? 'post' : 'pre'
|
||||
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
|
||||
return { id, label, phase }
|
||||
}
|
||||
|
||||
export function sanitizePodcastChecklist(value) {
|
||||
const fallback = buildDefaultPodcastChecklist()
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
|
||||
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
|
||||
const seenTaskIds = new Set()
|
||||
const tasks = []
|
||||
|
||||
for (const item of taskInput) {
|
||||
const safeTask = sanitizeChecklistTask(item)
|
||||
if (!safeTask) continue
|
||||
if (seenTaskIds.has(safeTask.id)) continue
|
||||
seenTaskIds.add(safeTask.id)
|
||||
tasks.push(safeTask)
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
for (const task of fallback.tasks) {
|
||||
tasks.push({ ...task })
|
||||
seenTaskIds.add(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const taskIds = tasks.map(task => task.id)
|
||||
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
|
||||
const episodes = []
|
||||
|
||||
for (const item of episodesInput) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
|
||||
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
|
||||
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
|
||||
const rawEpisodeNumber = Number(item.episodeNumber)
|
||||
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
|
||||
? Math.round(rawEpisodeNumber)
|
||||
: null
|
||||
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
|
||||
? item.datePublished.trim()
|
||||
: ''
|
||||
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
|
||||
? item.tasks
|
||||
: {}
|
||||
const taskState = {}
|
||||
for (const taskId of taskIds) {
|
||||
taskState[taskId] = sourceTasks[taskId] === true
|
||||
}
|
||||
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
|
||||
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
|
||||
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
|
||||
const PROD_STATUSES = ['idea', 'recorded', 'edited', 'scheduled', 'published']
|
||||
const productionStatus = PROD_STATUSES.includes(item.productionStatus) ? item.productionStatus : undefined
|
||||
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}), ...(productionStatus ? { productionStatus } : {}) })
|
||||
}
|
||||
|
||||
if (episodes.length === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return { tasks, episodes }
|
||||
}
|
||||
|
||||
function normalizeStudyUsername(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim().toLowerCase()
|
||||
@@ -1381,6 +1359,9 @@ export function sanitizeStudyUsers(value) {
|
||||
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
|
||||
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||
: []
|
||||
const completedStudySlugs = Array.isArray(item?.completedStudySlugs)
|
||||
? Array.from(new Set(item.completedStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER * 4)
|
||||
: []
|
||||
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
|
||||
const subscribeNewsletter = item?.subscribeNewsletter !== false
|
||||
const studyRemindersEnabled = item?.studyRemindersEnabled === true
|
||||
@@ -1394,6 +1375,18 @@ export function sanitizeStudyUsers(value) {
|
||||
requestedAt: typeof item.pendingEmailChange.requestedAt === 'string' ? item.pendingEmailChange.requestedAt : null,
|
||||
}
|
||||
: null
|
||||
const pendingEmailVerification = item?.pendingEmailVerification && typeof item.pendingEmailVerification === 'object' && !Array.isArray(item.pendingEmailVerification)
|
||||
? {
|
||||
tokenHash: typeof item.pendingEmailVerification.tokenHash === 'string' ? item.pendingEmailVerification.tokenHash.trim() : '',
|
||||
expiresAt: typeof item.pendingEmailVerification.expiresAt === 'number' ? item.pendingEmailVerification.expiresAt : 0,
|
||||
}
|
||||
: null
|
||||
const pendingPasswordReset = item?.pendingPasswordReset && typeof item.pendingPasswordReset === 'object' && !Array.isArray(item.pendingPasswordReset)
|
||||
? {
|
||||
tokenHash: typeof item.pendingPasswordReset.tokenHash === 'string' ? item.pendingPasswordReset.tokenHash.trim() : '',
|
||||
expiresAt: typeof item.pendingPasswordReset.expiresAt === 'number' ? item.pendingPasswordReset.expiresAt : 0,
|
||||
}
|
||||
: null
|
||||
seen.add(username)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
@@ -1403,12 +1396,22 @@ export function sanitizeStudyUsers(value) {
|
||||
subscribeNewsletter,
|
||||
studyRemindersEnabled,
|
||||
enrolledStudySlugs,
|
||||
completedStudySlugs,
|
||||
// Only the admin panel can set this (see PATCH /api/admin/study-users/:id)
|
||||
adminShortcut: item?.adminShortcut === true,
|
||||
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
|
||||
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
|
||||
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
||||
firstCompletionAt: typeof item?.firstCompletionAt === 'string' ? item.firstCompletionAt : null,
|
||||
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
|
||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
|
||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
|
||||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||
reengagementSentAt: typeof item?.reengagementSentAt === 'object' && item.reengagementSentAt !== null && !Array.isArray(item.reengagementSentAt) ? item.reengagementSentAt : {},
|
||||
pendingEmailChange,
|
||||
emailVerified: item?.emailVerified === true,
|
||||
pendingEmailVerification,
|
||||
pendingPasswordReset,
|
||||
twoFaMethod: item?.twoFaMethod === 'app' || item?.twoFaMethod === 'email' ? item.twoFaMethod : null,
|
||||
totpSecret: typeof item?.totpSecret === 'string' && item.totpSecret ? item.totpSecret : null,
|
||||
totpVerified: item?.totpVerified === true,
|
||||
@@ -1418,6 +1421,7 @@ export function sanitizeStudyUsers(value) {
|
||||
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
|
||||
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
|
||||
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
|
||||
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
|
||||
})
|
||||
}
|
||||
return out.slice(0, MAX_STUDY_USERS)
|
||||
@@ -1564,3 +1568,51 @@ export function loadQrCodesFromDisk() {
|
||||
state.qrScans = []
|
||||
})
|
||||
}
|
||||
|
||||
// ── Newsletter Issues ────────────────────────────────────────────────────────
|
||||
|
||||
export function queueNewsletterIssuesWrite() {
|
||||
state.newsletterIssuesWritePromise = state.newsletterIssuesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(NEWSLETTER_ISSUES_FILE, JSON.stringify({ issues: state.newsletterIssues }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[newsletter-issues] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadNewsletterIssuesFromDisk() {
|
||||
return readFile(NEWSLETTER_ISSUES_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.newsletterIssues = Array.isArray(parsed?.issues) ? parsed.issues : []
|
||||
})
|
||||
.catch(() => {
|
||||
state.newsletterIssues = []
|
||||
})
|
||||
}
|
||||
|
||||
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
|
||||
|
||||
export function queueNuggetsTagsWrite() {
|
||||
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[nuggets-tags] failed to write:', err)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadNuggetsTagsFromDisk() {
|
||||
return readFile(NUGGETS_TAGS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
|
||||
})
|
||||
.catch(() => {
|
||||
state.nuggetsEpisodeTags = {}
|
||||
})
|
||||
}
|
||||
+256
-8
@@ -2,6 +2,8 @@ import { Resend } from 'resend'
|
||||
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
|
||||
import {
|
||||
DEFAULT_RESEND_FROM,
|
||||
DEFAULT_RESEND_TO,
|
||||
DEFAULT_RESEND_REPLY_TO,
|
||||
DEFAULT_SEO,
|
||||
} from './config.js'
|
||||
import { state } from './state.js'
|
||||
@@ -13,11 +15,11 @@ export function getResendFromAddress() {
|
||||
}
|
||||
|
||||
export function getResendReplyToAddress() {
|
||||
return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us'
|
||||
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
|
||||
}
|
||||
|
||||
export function getResendInboxAddress() {
|
||||
return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
|
||||
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
||||
}
|
||||
|
||||
export function getCanonicalBaseUrl() {
|
||||
@@ -82,22 +84,40 @@ export async function sendResendEmailWithRetry({ resend, payload, context, maxAt
|
||||
throw lastError ?? new Error(`[${context}] email send failed`)
|
||||
}
|
||||
|
||||
// Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing.
|
||||
export async function resendRetry(call, maxAttempts = 5) {
|
||||
let result
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
result = await call()
|
||||
const err = result?.error
|
||||
const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded')
|
||||
if (!limited) return result
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function syncContactToResend(name, email) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
const { firstName, lastName } = splitName(name)
|
||||
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
try {
|
||||
const { error: contactError } = await contactResend.contacts.create({
|
||||
// Never touch `unsubscribed` for a contact that already exists — otherwise a form submission
|
||||
// would silently resubscribe someone who opted out in Resend.
|
||||
const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email }))
|
||||
if (existing) {
|
||||
const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName }))
|
||||
if (updateError) console.error('[resend] contact sync error:', updateError)
|
||||
return
|
||||
}
|
||||
const { error: createError } = await resendRetry(() => contactResend.contacts.create({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
unsubscribed: false,
|
||||
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
|
||||
})
|
||||
if (contactError) {
|
||||
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName, unsubscribed: false })
|
||||
if (updateError) console.error('[resend] contact sync error:', updateError)
|
||||
}
|
||||
}))
|
||||
if (createError) console.error('[resend] contact sync error:', createError)
|
||||
} catch (err) {
|
||||
console.error('[resend] contact sync exception:', err)
|
||||
}
|
||||
@@ -483,6 +503,106 @@ export async function sendStudyWelcomeEmail(email, displayName) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyVerifyEmail(email, displayName, verifyUrl) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const subject = cfg.emailVerifySubject?.trim() || 'Verify your email address'
|
||||
const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.'
|
||||
const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\n${verifyUrl}\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Verify Your Email',
|
||||
eyebrow: 'Study Account',
|
||||
bodyHtml,
|
||||
ctaLabel,
|
||||
ctaUrl: verifyUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't create this account, you can ignore this message.</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-verify-email] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-verify-email] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const subject = cfg.passwordResetSubject?.trim() || 'Reset your password'
|
||||
const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.'
|
||||
const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\n${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Reset Your Password',
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml,
|
||||
ctaLabel,
|
||||
ctaUrl: resetUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't request this, you can safely ignore this email — your password won't change.</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-password-reset] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-password-reset] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendStudyPasswordChangedEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const cfg = state.cachedSiteContent ?? {}
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||||
const subject = cfg.passwordChangedSubject?.trim() || 'Your password was changed'
|
||||
const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.'
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
|
||||
)
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
|
||||
to: [email],
|
||||
subject,
|
||||
text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Password Changed',
|
||||
eyebrow: 'Account Security',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Manage Account',
|
||||
ctaUrl: accountUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-password-changed] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[study-password-changed] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailOtp(email, code) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
@@ -712,3 +832,131 @@ export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, un
|
||||
console.error('[study-digest] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nuggets upload notifications ───────────────────────────────────────────
|
||||
|
||||
export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile'
|
||||
const subject = `New Nuggets Episode Upload: ${episodeName || filename}`
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">A new episode file has arrived in Seafile.</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;">
|
||||
<tr><td style="padding:6px 0;color:#7a7060;width:120px;">Source</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(sourceLabel)}</td></tr>
|
||||
${hostName ? `<tr><td style="padding:6px 0;color:#7a7060;">Host</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(hostName)}</td></tr>` : ''}
|
||||
${episodeName ? `<tr><td style="padding:6px 0;color:#7a7060;">Episode</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(episodeName)}</td></tr>` : ''}
|
||||
${passage ? `<tr><td style="padding:6px 0;color:#7a7060;">Passage</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(passage)}</td></tr>` : ''}
|
||||
<tr><td style="padding:6px 0;color:#7a7060;">Audio File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#c9a84c;">${escapeHtml(filename)}</td></tr>
|
||||
${infoFile ? `<tr><td style="padding:6px 0;color:#7a7060;">Info File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#7a7060;">${escapeHtml(infoFile)}</td></tr>` : ''}
|
||||
</table>
|
||||
${description ? `<p style="margin:16px 0 0;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;"><strong style="color:#f0ead8;">Description:</strong><br/>${escapeHtml(description).replace(/\n/g, '<br/>')}</p>` : ''}
|
||||
`
|
||||
|
||||
const textBody = [
|
||||
`New Nuggets Episode Upload (${sourceLabel})`,
|
||||
'',
|
||||
hostName ? `Host: ${hostName}` : '',
|
||||
episodeName ? `Episode: ${episodeName}` : '',
|
||||
passage ? `Passage: ${passage}` : '',
|
||||
`Audio File: ${filename}`,
|
||||
infoFile ? `Info File: ${infoFile}` : '',
|
||||
description ? `\nDescription:\n${description}` : '',
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to: [to],
|
||||
subject,
|
||||
text: textBody,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'New Episode Upload',
|
||||
eyebrow: 'Nuggets',
|
||||
bodyHtml,
|
||||
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">This notification was sent because a file was uploaded to the Nuggets Seafile library.</p>',
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[nuggets-notify] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-notify] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) {
|
||||
if (!process.env.RESEND_API_KEY || !to) return
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
|
||||
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
|
||||
You've been set up as a host on the <strong style="color:#f0ead8;">Nuggets</strong> episode upload portal.
|
||||
Use the details below to sign in and submit your episodes.
|
||||
</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;margin-bottom:20px;">
|
||||
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;width:110px;">Portal URL</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;"><a href="${escapeHtml(portalUrl)}" style="color:#c9a84c;">${escapeHtml(portalUrl)}</a></td></tr>
|
||||
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Email</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(username)}</td></tr>
|
||||
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Password</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(password)}</td></tr>
|
||||
</table>
|
||||
<p style="margin:0 0 10px;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;">When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.</p>
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you have any trouble signing in, reply to this email and we'll get you sorted.</p>
|
||||
`
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to: [to],
|
||||
subject: 'Your Nuggets Host Upload Portal Access',
|
||||
text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Upload Portal Access',
|
||||
eyebrow: 'Nuggets',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Open Upload Portal',
|
||||
ctaUrl: portalUrl,
|
||||
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because an admin created an upload account for you on the Nuggets podcast portal.</p>',
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[nuggets-welcome] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-welcome] send exception:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNuggetsHostOtpEmail({ to, name, code }) {
|
||||
if (!process.env.RESEND_API_KEY || !to) return
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
|
||||
const bodyHtml = `
|
||||
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
|
||||
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
|
||||
Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
|
||||
</p>
|
||||
<div style="margin:0 0 24px;padding:20px 24px;background:#1a1a15;border:1px solid #2a2518;border-radius:4px;text-align:center;">
|
||||
<span style="font-family:monospace;font-size:36px;font-weight:700;letter-spacing:8px;color:#c9a84c;">${escapeHtml(code)}</span>
|
||||
</div>
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you did not request this code, you can safely ignore this email.</p>
|
||||
`
|
||||
|
||||
try {
|
||||
const { error } = await resend.emails.send({
|
||||
from,
|
||||
to: [to],
|
||||
subject: 'Your Nuggets Portal Sign-In Code',
|
||||
text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Sign-In Code',
|
||||
eyebrow: 'Nuggets',
|
||||
bodyHtml,
|
||||
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because a sign-in was attempted on the Nuggets podcast host portal.</p>',
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[nuggets-otp] send error:', error)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-otp] send exception:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible
|
||||
// is its own podcast season and episode numbers can restart in every season. A script with no
|
||||
// season keeps its plain episode number as its key.
|
||||
|
||||
export function scriptKey(season, episodeNumber) {
|
||||
const ep = String(episodeNumber ?? '').trim()
|
||||
const s = String(season ?? '').trim()
|
||||
return /^\d+$/.test(s) ? `${s}-${ep}` : ep
|
||||
}
|
||||
|
||||
// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it).
|
||||
export function scriptEpisodeNumber(key, entry) {
|
||||
if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber
|
||||
const parts = String(key).split('-')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }).
|
||||
// Scripts without a season stay under their plain episode number. Never overwrites an existing key.
|
||||
export function migrateScriptKeys(scripts) {
|
||||
const next = {}
|
||||
let changed = false
|
||||
for (const [key, entry] of Object.entries(scripts ?? {})) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const wanted = scriptKey(entry.season, episodeNumber)
|
||||
const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key
|
||||
const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber }
|
||||
if (targetKey !== key || withEpisode !== entry) changed = true
|
||||
next[targetKey] = withEpisode
|
||||
}
|
||||
return { scripts: next, changed }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fetchAllEpisodes } from './routes/episodes.js'
|
||||
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||
|
||||
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
|
||||
// showing up there means the episode has been published. That drives two things on the public site:
|
||||
// - highlights marked "hold until released" stay hidden until their episode is in the feed
|
||||
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
|
||||
|
||||
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
let snapshot = { items: [], at: 0 }
|
||||
let inflight = null
|
||||
|
||||
function refreshSnapshot() {
|
||||
if (!inflight) {
|
||||
inflight = fetchAllEpisodes()
|
||||
.then(items => { snapshot = { items, at: Date.now() } })
|
||||
// Keep serving the last good copy; retry on the next TTL window
|
||||
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
|
||||
.finally(() => { inflight = null })
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
|
||||
export async function getFeedIndex() {
|
||||
if (!snapshot.at) await refreshSnapshot()
|
||||
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
|
||||
|
||||
const index = new Map()
|
||||
for (const item of snapshot.items) {
|
||||
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
export function warmFeedSnapshot() {
|
||||
return refreshSnapshot()
|
||||
}
|
||||
|
||||
// Returns a copy of the published site content with the feed applied. Run it before
|
||||
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
|
||||
export async function applyFeedRelease(content) {
|
||||
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
|
||||
|
||||
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||
if (links.length === 0 && studies.length === 0) return content
|
||||
|
||||
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
|
||||
const seasonA = laneSeasonOf('A', wf, content)
|
||||
const seasonB = laneSeasonOf('B', wf, content)
|
||||
|
||||
const nextLinks = links.flatMap(link => {
|
||||
// General highlights aren't tied to a series, so there's no season to match against
|
||||
if (!link || link.lane === 'general') return [link]
|
||||
const season = link.lane === 'second' ? seasonB : seasonA
|
||||
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
|
||||
if (!item) return link.holdUntilReleased ? [] : [link]
|
||||
// Highlights with no audio of their own pick it up from the matching feed episode
|
||||
return [{
|
||||
...link,
|
||||
url: link.url || item.link || '',
|
||||
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
|
||||
}]
|
||||
})
|
||||
|
||||
const studyLane = study => {
|
||||
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
|
||||
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
|
||||
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
|
||||
return ''
|
||||
}
|
||||
|
||||
const nextStudies = studies.map(study => {
|
||||
const lane = studyLane(study)
|
||||
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
|
||||
if (!season || !Array.isArray(study.sections)) return study
|
||||
return {
|
||||
...study,
|
||||
sections: study.sections.map(section => {
|
||||
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
|
||||
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
|
||||
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
|
||||
}
|
||||
+67
-2
@@ -134,14 +134,26 @@ export function sanitizeFeaturedLinks(value) {
|
||||
.slice(0, 30)
|
||||
: []
|
||||
|
||||
const sanitizeStringArray = (val, limit) =>
|
||||
Array.isArray(val)
|
||||
? val.filter(s => typeof s === 'string').map(s => s.trim()).filter(Boolean).slice(0, limit)
|
||||
: []
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
||||
holdUntilReleased: item.holdUntilReleased === true,
|
||||
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) : '',
|
||||
showNotesSummary: typeof item.showNotesSummary === 'string' ? item.showNotesSummary.trim().slice(0, 1000) : '',
|
||||
showNotesThemes: sanitizeStringArray(item.showNotesThemes, 20),
|
||||
showNotesScripture: sanitizeStringArray(item.showNotesScripture, 30),
|
||||
showNotesQuotes: sanitizeStringArray(item.showNotesQuotes, 20),
|
||||
showNotesResources: sanitizeStringArray(item.showNotesResources, 30),
|
||||
discussionQuestions,
|
||||
}
|
||||
})
|
||||
@@ -152,6 +164,11 @@ export function sanitizeFeaturedLinks(value) {
|
||||
item.url ||
|
||||
item.embedUrl ||
|
||||
item.showNotes ||
|
||||
item.showNotesSummary ||
|
||||
item.showNotesThemes.length > 0 ||
|
||||
item.showNotesScripture.length > 0 ||
|
||||
item.showNotesQuotes.length > 0 ||
|
||||
item.showNotesResources.length > 0 ||
|
||||
item.discussionQuestions.length > 0,
|
||||
)
|
||||
}
|
||||
@@ -253,6 +270,7 @@ function sanitizeColossiansStudySections(value) {
|
||||
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
|
||||
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||||
episodeNumber: typeof item.episodeNumber === 'string' || typeof item.episodeNumber === 'number' ? String(item.episodeNumber).trim().slice(0, 20) : '',
|
||||
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
|
||||
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
|
||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||
@@ -274,6 +292,45 @@ function sanitizeColossiansStudySections(value) {
|
||||
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
||||
}
|
||||
|
||||
function sanitizeIsoDate(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return ''
|
||||
const time = Date.parse(value)
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : ''
|
||||
}
|
||||
|
||||
function sanitizeStudyGuide(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||
const title = str(value.title, 140)
|
||||
if (!title) return undefined
|
||||
return {
|
||||
title,
|
||||
description: str(value.description, 4000),
|
||||
downloadUrl: sanitizeUrl(value.downloadUrl),
|
||||
amazonUrl: sanitizeUrl(value.amazonUrl),
|
||||
amazonLabel: str(value.amazonLabel, 80),
|
||||
appleBooksUrl: sanitizeUrl(value.appleBooksUrl),
|
||||
appleBooksLabel: str(value.appleBooksLabel, 80),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSecondSeries(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
|
||||
return {
|
||||
label: str(value.label, 80) || 'Now Playing',
|
||||
title: str(value.title, 160),
|
||||
description: str(value.description, 800),
|
||||
imageUrl: str(value.imageUrl, 500),
|
||||
listenUrl: str(value.listenUrl, 500),
|
||||
listenBtnLabel: str(value.listenBtnLabel, 80),
|
||||
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
||||
...(season ? { season } : {}),
|
||||
...(sanitizeStudyGuide(value.guide) ? { guide: sanitizeStudyGuide(value.guide) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStudies(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
@@ -284,10 +341,14 @@ function sanitizeStudies(value) {
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
|
||||
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
|
||||
imageUrl: sanitizeUrl(item.imageUrl) || '',
|
||||
showOnHomepage: item.showOnHomepage === true,
|
||||
showNewTag: item.showNewTag === true,
|
||||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||
status: item.status === 'planned' ? 'planned' : 'active',
|
||||
guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
|
||||
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
||||
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
||||
startsAt: sanitizeIsoDate(item.startsAt),
|
||||
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
||||
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
||||
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
||||
@@ -339,7 +400,7 @@ function sanitizeNuggets(raw) {
|
||||
id: h.id.trim(),
|
||||
name: typeof h.name === 'string' ? h.name.trim().slice(0, 120) : '',
|
||||
role: typeof h.role === 'string' ? h.role.trim().slice(0, 120) : '',
|
||||
bio: typeof h.bio === 'string' ? h.bio.trim().slice(0, 600) : '',
|
||||
bio: typeof h.bio === 'string' ? h.bio.trim().slice(0, 4000) : '',
|
||||
imageUrl: sanitizeUrl(h.imageUrl) || '',
|
||||
archived: Boolean(h.archived),
|
||||
}))
|
||||
@@ -368,6 +429,10 @@ export function sanitizeSiteContent(siteContent) {
|
||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||
studies: sanitizeStudies(siteContent.studies),
|
||||
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
|
||||
finishedBooks: Array.isArray(siteContent.finishedBooks)
|
||||
? siteContent.finishedBooks.map(book => (book && typeof book === 'object' ? { ...book, guide: sanitizeStudyGuide(book.guide) } : book))
|
||||
: siteContent.finishedBooks,
|
||||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { writeFile } from './atomic-write.js'
|
||||
import { sendNuggetsUploadNotification } from './email.js'
|
||||
import {
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_POLL_INTERVAL_MS,
|
||||
NUGGETS_SEAFILE_SEEN_FILE,
|
||||
NUGGETS_NOTIFY_EMAIL,
|
||||
} from './config.js'
|
||||
|
||||
async function loadSeen() {
|
||||
try {
|
||||
return JSON.parse(await readFile(NUGGETS_SEAFILE_SEEN_FILE, 'utf8'))
|
||||
} catch {
|
||||
return { files: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeen(data) {
|
||||
await writeFile(NUGGETS_SEAFILE_SEEN_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// Called by the upload route after a successful portal upload so the poller
|
||||
// doesn't also fire a notification for the same file.
|
||||
export async function markSeafileFileSeen(filename) {
|
||||
const data = await loadSeen()
|
||||
if (!data.files.includes(filename)) {
|
||||
data.files.push(filename)
|
||||
await saveSeen(data)
|
||||
}
|
||||
}
|
||||
|
||||
async function listSeafileDir(dir) {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return []
|
||||
try {
|
||||
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/dir/?p=${encodeURIComponent(dir)}`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return (data.dirent ?? []).filter(e => e.type === 'file').map(e => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return
|
||||
|
||||
const [uploadFiles, seen] = await Promise.all([
|
||||
listSeafileDir(SEAFILE_EPISODE_UPLOAD_DIR),
|
||||
loadSeen(),
|
||||
])
|
||||
|
||||
const seenSet = new Set(seen.files)
|
||||
const newFiles = uploadFiles.filter(f => !seenSet.has(f))
|
||||
|
||||
if (newFiles.length === 0) return
|
||||
|
||||
for (const filename of newFiles) {
|
||||
console.log(`[nuggets-watcher] new direct upload detected: ${filename}`)
|
||||
await sendNuggetsUploadNotification({
|
||||
to: NUGGETS_NOTIFY_EMAIL,
|
||||
source: 'direct',
|
||||
filename,
|
||||
})
|
||||
seenSet.add(filename)
|
||||
}
|
||||
|
||||
seen.files = [...seenSet]
|
||||
await saveSeen(seen)
|
||||
}
|
||||
|
||||
export function startNuggetsSeafileWatcher() {
|
||||
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) {
|
||||
console.log('[nuggets-watcher] Seafile not configured — watcher disabled.')
|
||||
return
|
||||
}
|
||||
// Initial poll after 2 min (let the server settle), then every 15 min
|
||||
setTimeout(() => {
|
||||
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
|
||||
setInterval(() => {
|
||||
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
|
||||
}, SEAFILE_POLL_INTERVAL_MS)
|
||||
}, 2 * 60 * 1000)
|
||||
|
||||
console.log(`[nuggets-watcher] started — polling every ${SEAFILE_POLL_INTERVAL_MS / 60000} min`)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
||||
import { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL } from './config.js'
|
||||
|
||||
function makeClient() {
|
||||
return new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
||||
credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY },
|
||||
})
|
||||
}
|
||||
|
||||
export function isR2Configured() {
|
||||
return Boolean(R2_ACCOUNT_ID && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_BUCKET && R2_PUBLIC_URL)
|
||||
}
|
||||
|
||||
export async function uploadToR2(buffer, key, contentType) {
|
||||
await makeClient().send(new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}))
|
||||
return `${R2_PUBLIC_URL}/${key}`
|
||||
}
|
||||
|
||||
export async function deleteFromR2(key) {
|
||||
await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }))
|
||||
}
|
||||
|
||||
export async function copyInR2(fromKey, toKey) {
|
||||
await makeClient().send(new CopyObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
CopySource: `${R2_BUCKET}/${fromKey.split('/').map(encodeURIComponent).join('/')}`,
|
||||
Key: toKey,
|
||||
}))
|
||||
return `${R2_PUBLIC_URL}/${toKey}`
|
||||
}
|
||||
|
||||
export async function listR2Objects(prefix) {
|
||||
const objects = []
|
||||
let continuationToken
|
||||
const client = makeClient()
|
||||
do {
|
||||
const resp = await client.send(new ListObjectsV2Command({
|
||||
Bucket: R2_BUCKET,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}))
|
||||
for (const obj of resp.Contents ?? []) objects.push(obj)
|
||||
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined
|
||||
} while (continuationToken)
|
||||
return objects
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Resend } from 'resend'
|
||||
import { state } from './state.js'
|
||||
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js'
|
||||
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
|
||||
|
||||
function toDateKey(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function startReminderScheduler() {
|
||||
checkReminders()
|
||||
setInterval(checkReminders, 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
async function checkReminders() {
|
||||
const todayKey = toDateKey(new Date())
|
||||
|
||||
// Episodes
|
||||
const episodes = state.podcastChecklist?.episodes
|
||||
if (Array.isArray(episodes)) {
|
||||
let changed = false
|
||||
for (const ep of episodes) {
|
||||
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
|
||||
const publish = new Date(ep.datePublished + 'T12:00:00Z')
|
||||
if (isNaN(publish.getTime())) continue
|
||||
const reminderDate = new Date(publish)
|
||||
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
|
||||
if (todayKey === toDateKey(reminderDate)) {
|
||||
const sent = await sendReminderEmail({
|
||||
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
|
||||
title: ep.title || 'Untitled',
|
||||
series: ep.series,
|
||||
date: ep.datePublished,
|
||||
reminderDays: ep.reminderDays,
|
||||
type: 'episode',
|
||||
})
|
||||
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
|
||||
}
|
||||
}
|
||||
if (changed) queuePodcastChecklistWrite()
|
||||
}
|
||||
|
||||
// Calendar events
|
||||
const events = state.calendarEvents
|
||||
if (Array.isArray(events)) {
|
||||
let changed = false
|
||||
for (const ev of events) {
|
||||
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
|
||||
const publish = new Date(ev.date + 'T12:00:00Z')
|
||||
if (isNaN(publish.getTime())) continue
|
||||
const reminderDate = new Date(publish)
|
||||
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
|
||||
if (todayKey === toDateKey(reminderDate)) {
|
||||
const sent = await sendReminderEmail({
|
||||
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
|
||||
title: ev.title,
|
||||
series: null,
|
||||
date: ev.date,
|
||||
reminderDays: ev.reminderDays,
|
||||
type: ev.type,
|
||||
})
|
||||
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
|
||||
}
|
||||
}
|
||||
if (changed) queueCalendarEventsWrite()
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
|
||||
|
||||
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
|
||||
if (!process.env.RESEND_API_KEY) return false
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
|
||||
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
||||
|
||||
const eventDate = new Date(date + 'T12:00:00Z')
|
||||
const msLeft = eventDate.getTime() - Date.now()
|
||||
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
|
||||
|
||||
const seriesStr = series ? ` (${series})` : ''
|
||||
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
|
||||
const icon = TYPE_ICONS[type] ?? '📅'
|
||||
const subject = `Reminder: ${label}: ${title} — ${daysText}`
|
||||
|
||||
const html = `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
|
||||
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
|
||||
<p style="font-size:1.1rem;margin-bottom:16px">
|
||||
<strong>${label}: ${title}</strong>${seriesStr}<br>
|
||||
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
|
||||
<p style="color:#777;font-size:0.85rem">
|
||||
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
|
||||
To change or remove it, open the Calendar in your admin panel.
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
const { error } = await resend.emails.send({ from, to, subject, html })
|
||||
if (error) { console.error('[reminder] send error:', error); return false }
|
||||
console.log(`[reminder] sent for "${title}" (${date})`)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('[reminder] send exception:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
+122
-30
@@ -1,8 +1,9 @@
|
||||
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { UPLOADS_DIR } from '../config.js'
|
||||
import { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
listUploadedAssets,
|
||||
@@ -18,14 +19,26 @@ import {
|
||||
normalizeStudySlug,
|
||||
isEnrollableStudySlug,
|
||||
makeUnsubUrl,
|
||||
invalidateAllStudySessions,
|
||||
} from '../study-helpers.js'
|
||||
import { sendStudyReengagementEmail } from '../email.js'
|
||||
import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js'
|
||||
|
||||
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
|
||||
|
||||
function isSafeFilename(filename) {
|
||||
return typeof filename === 'string' && filename.length > 0 && !filename.includes('/') && !filename.includes('\\') && !filename.includes('..')
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const assets = await listUploadedAssets()
|
||||
res.json({ assets })
|
||||
const haystack = JSON.stringify(state.cachedDraftSiteContent ?? {}) + JSON.stringify(state.cachedSiteContent ?? {})
|
||||
const withUsage = assets.map(asset => ({
|
||||
...asset,
|
||||
usedIn: asset.url ? haystack.split(asset.url).length - 1 : 0,
|
||||
}))
|
||||
res.json({ assets: withUsage })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Could not list uploaded assets.' })
|
||||
}
|
||||
@@ -35,8 +48,13 @@ export function register(app) {
|
||||
try {
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
|
||||
const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2'
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
|
||||
const useLocal = rawStorage === 'local' || !isR2Configured()
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||||
return
|
||||
@@ -52,14 +70,23 @@ export function register(app) {
|
||||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||||
const finalName = `${baseName}-${Date.now()}${ext}`
|
||||
|
||||
let url
|
||||
if (useLocal) {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
url = `/uploads/${finalName}`
|
||||
} else {
|
||||
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
|
||||
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
|
||||
}
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[finalName] = []
|
||||
metadata[finalName] = { tags: [], url, folder: useLocal ? 'local' : folder }
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||||
} catch {
|
||||
res.json({ ok: true, asset: { filename: finalName, url, folder: useLocal ? 'local' : folder, storage: useLocal ? 'local' : 'r2' } })
|
||||
} catch (err) {
|
||||
console.error('[admin-assets] upload failed:', err)
|
||||
res.status(500).json({ message: 'Upload failed.' })
|
||||
}
|
||||
})
|
||||
@@ -67,43 +94,105 @@ export function register(app) {
|
||||
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||||
if (!isSafeFilename(filename)) {
|
||||
res.status(400).json({ message: 'Invalid filename.' })
|
||||
return
|
||||
}
|
||||
|
||||
const tags = Array.isArray(req.body?.tags)
|
||||
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename)
|
||||
await stat(filePath)
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[filename] = tags
|
||||
const current = (await listUploadedAssets()).find(a => a.filename === filename)
|
||||
if (!current) {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const entry = metadata[filename]
|
||||
let tags = Array.isArray(entry)
|
||||
? entry
|
||||
: Array.isArray(entry?.tags) ? entry.tags : []
|
||||
if (Array.isArray(req.body?.tags)) {
|
||||
tags = req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
let newFilename = filename
|
||||
let newUrl = current.url
|
||||
let newFolder = current.folder
|
||||
const ext = path.extname(filename)
|
||||
|
||||
const wantsRename = typeof req.body?.newName === 'string' && req.body.newName.trim()
|
||||
const wantsMove = typeof req.body?.folder === 'string' && req.body.folder !== current.folder
|
||||
|
||||
if (wantsRename) {
|
||||
const base = normalizeAssetBaseName(req.body.newName.trim().replace(/\.[a-z0-9]+$/i, ''))
|
||||
newFilename = `${base}${ext}`
|
||||
}
|
||||
if (wantsMove) {
|
||||
if (current.storage !== 'r2' || !ALLOWED_FOLDERS.includes(req.body.folder)) {
|
||||
res.status(400).json({ message: 'Only R2 assets can be moved, to uploads, images, guides, or finished.' })
|
||||
return
|
||||
}
|
||||
newFolder = req.body.folder
|
||||
}
|
||||
|
||||
if (newFilename !== filename || newFolder !== current.folder) {
|
||||
if (newFilename !== filename) {
|
||||
const taken = (await listUploadedAssets()).some(a => a.filename === newFilename && a.folder === newFolder)
|
||||
if (taken) {
|
||||
res.status(409).json({ message: `A file named ${newFilename} already exists there.` })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (current.storage === 'r2') {
|
||||
const newKey = `${newFolder}/${newFilename}`
|
||||
newUrl = await copyInR2(current.key, newKey)
|
||||
await deleteFromR2(current.key)
|
||||
} else {
|
||||
await rename(path.join(UPLOADS_DIR, filename), path.join(UPLOADS_DIR, newFilename))
|
||||
newUrl = `/uploads/${newFilename}`
|
||||
}
|
||||
delete metadata[filename]
|
||||
}
|
||||
|
||||
metadata[newFilename] = { tags, url: newUrl, folder: current.storage === 'r2' ? newFolder : 'local' }
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, tags })
|
||||
} catch {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
res.json({ ok: true, tags, filename: newFilename, url: newUrl, folder: newFolder, oldUrl: current.url })
|
||||
} catch (err) {
|
||||
console.error('[admin-assets] update failed:', err)
|
||||
res.status(500).json({ message: 'Could not update asset.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params
|
||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
||||
if (!isSafeFilename(filename)) {
|
||||
res.status(400).json({ message: 'Invalid filename.' })
|
||||
return
|
||||
}
|
||||
|
||||
await unlink(path.join(UPLOADS_DIR, filename))
|
||||
// Optional ?folder= disambiguates identical filenames in different R2 folders.
|
||||
const folder = typeof req.query.folder === 'string' ? req.query.folder : null
|
||||
const matches = (await listUploadedAssets()).filter(a => a.filename === filename && (!folder || a.folder === folder))
|
||||
const target = matches[0]
|
||||
if (!target) {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (target.storage === 'r2') {
|
||||
await deleteFromR2(target.key)
|
||||
} else {
|
||||
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
|
||||
}
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
delete metadata[filename]
|
||||
await writeUploadsMetadata(metadata)
|
||||
res.json({ ok: true })
|
||||
} catch {
|
||||
res.status(404).json({ message: 'Asset not found.' })
|
||||
} catch (err) {
|
||||
console.error('[admin-assets] delete failed:', err)
|
||||
res.status(500).json({ message: 'Delete failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -150,6 +239,7 @@ export function register(app) {
|
||||
currentStreak: user.currentStreak ?? 0,
|
||||
longestStreak: user.longestStreak ?? 0,
|
||||
reengagementSentAt: user.reengagementSentAt ?? {},
|
||||
adminShortcut: user.adminShortcut === true,
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -193,20 +283,24 @@ export function register(app) {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
|
||||
const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {}
|
||||
|
||||
if (typeof displayName === 'string') {
|
||||
user.displayName = displayName.trim().slice(0, 80)
|
||||
}
|
||||
|
||||
if (typeof adminShortcut === 'boolean') {
|
||||
user.adminShortcut = adminShortcut
|
||||
}
|
||||
|
||||
if (typeof newPassword === 'string') {
|
||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
||||
}
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||
.catch(err => console.error('[admin] password-changed email error:', err))
|
||||
}
|
||||
|
||||
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
|
||||
@@ -225,16 +319,14 @@ export function register(app) {
|
||||
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
|
||||
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
|
||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
@@ -3,6 +3,7 @@ import qrcode from 'qrcode'
|
||||
import { parseCookies } from '../helpers.js'
|
||||
import { APP_VERSION, GIT_COMMIT } from '../config.js'
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
isAdminPasswordConfigured,
|
||||
isValidAdminSession,
|
||||
requireAdminAuth,
|
||||
@@ -24,8 +25,6 @@ import {
|
||||
consumePendingSession,
|
||||
} from '../auth.js'
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
loadDownloadCountsFromDisk,
|
||||
loadQrCodesFromDisk,
|
||||
loadEpisodePlaysFromDisk,
|
||||
loadPodcastChecklistFromDisk,
|
||||
loadAnalyticsEventsFromDisk,
|
||||
createBackupSnapshot,
|
||||
refreshContentCaches,
|
||||
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
|
||||
state.draftQuestionsWritePromise,
|
||||
state.replyTemplatesWritePromise,
|
||||
state.replyHistoryWritePromise,
|
||||
state.podcastChecklistWritePromise,
|
||||
state.studyUsersWritePromise,
|
||||
state.studyCommunityWritePromise,
|
||||
state.studyRemindersWritePromise,
|
||||
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadQrCodesFromDisk(),
|
||||
loadEpisodePlaysFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
loadAnalyticsEventsFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
|
||||
+200
-23
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { applyFeedRelease } from '../feed-release.js'
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import { assertPublicHttpsUrl } from '../url-safety.js'
|
||||
import { sanitizeSiteContent } from '../helpers.js'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import {
|
||||
@@ -24,13 +27,11 @@ import {
|
||||
queueContactSubmissionsWrite,
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queuePodcastChecklistWrite,
|
||||
queueWebhooksWrite,
|
||||
createBackupSnapshot,
|
||||
listBackupPreviews,
|
||||
readBackupPreview,
|
||||
restoreFromBackup,
|
||||
sanitizePodcastChecklist,
|
||||
} from '../data.js'
|
||||
import {
|
||||
pruneStatsByDays,
|
||||
@@ -53,6 +54,191 @@ function invokeWebhook(url, action) {
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
|
||||
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
|
||||
const SERIES_LANES = [
|
||||
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
|
||||
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
|
||||
]
|
||||
|
||||
function readCurrentSeries(content, lane) {
|
||||
if (lane === 'second') {
|
||||
const s = content.secondSeries ?? {}
|
||||
return {
|
||||
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
|
||||
season: s.season, studySlug: s.studySlug ?? '',
|
||||
guide: s.guide?.title ? s.guide : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
|
||||
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
|
||||
// The main series' guide lives in the flat studyGuide* fields
|
||||
guide: content.studyGuideTitle?.trim()
|
||||
? {
|
||||
title: content.studyGuideTitle,
|
||||
description: content.studyGuideDescription ?? '',
|
||||
downloadUrl: content.studyGuideDownloadUrl ?? '',
|
||||
amazonUrl: content.studyGuideUrl ?? '',
|
||||
amazonLabel: content.studyGuideAmazonButtonLabel ?? '',
|
||||
appleBooksUrl: content.studyGuideAppleBooksUrl ?? '',
|
||||
appleBooksLabel: content.studyGuideAppleBooksLabel ?? '',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the content that results from applying one lane's scheduled series transition.
|
||||
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
|
||||
function applySeriesTransition(content, sched, lane = 'primary') {
|
||||
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
|
||||
const current = readCurrentSeries(content, lane)
|
||||
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
|
||||
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
|
||||
|
||||
// Highlights for the ending series move onto its Finished Book; the other lane's and general ones stay.
|
||||
const allHighlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const isThisLane = link => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary') === lane
|
||||
const endingHighlights = current.title.trim() ? allHighlights.filter(isThisLane) : []
|
||||
const keptHighlights = current.title.trim() ? allHighlights.filter(link => !isThisLane(link)) : allHighlights
|
||||
|
||||
const archivedBook = current.title.trim()
|
||||
? {
|
||||
id: Date.now().toString(36),
|
||||
title: current.title,
|
||||
description: current.description,
|
||||
imageUrl: current.imageUrl,
|
||||
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
|
||||
...(current.studySlug ? { studySlug: current.studySlug } : {}),
|
||||
...(current.guide ? { guide: current.guide } : {}),
|
||||
...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}),
|
||||
}
|
||||
: null
|
||||
|
||||
const patch = {}
|
||||
const copy = (from, to) => {
|
||||
if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim()
|
||||
}
|
||||
copy('newStartHereHeading', 'startHereHeading')
|
||||
copy('newStartHereIntro', 'startHereIntro')
|
||||
for (const n of [1, 2, 3]) {
|
||||
copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`)
|
||||
copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`)
|
||||
copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`)
|
||||
}
|
||||
copy('newStudyGuideTitle', 'studyGuideTitle')
|
||||
copy('newStudyGuideDescription', 'studyGuideDescription')
|
||||
copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl')
|
||||
copy('newStudyGuideUrl', 'studyGuideUrl')
|
||||
copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel')
|
||||
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
||||
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
||||
|
||||
// The finished series' study becomes Complete (still enrollable, self-paced); the
|
||||
// incoming series' study opens up, taking the lane it launches in.
|
||||
const laneLetter = lane === 'second' ? 'B' : 'A'
|
||||
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
|
||||
if (!study) return study
|
||||
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
|
||||
return { ...study, status: 'complete' }
|
||||
}
|
||||
if (newStudySlug && study.slug === newStudySlug) {
|
||||
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
|
||||
}
|
||||
return study
|
||||
})
|
||||
|
||||
const lanePatch = lane === 'second'
|
||||
? {
|
||||
secondSeries: {
|
||||
...(content.secondSeries ?? {}),
|
||||
label: 'Now Playing',
|
||||
title: sched.newTitle.trim(),
|
||||
description: sched.newDescription?.trim() ?? '',
|
||||
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
|
||||
studySlug: newStudySlug,
|
||||
// The ending series' guide moved onto its Finished Book; the incoming series brings its own
|
||||
guide: sched.newGuide?.title?.trim() ? sched.newGuide : undefined,
|
||||
...(newSeason ? { season: newSeason } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||
seriesLabel: 'Now Playing',
|
||||
seriesStudySlug: newStudySlug,
|
||||
...(newSeason ? { seriesSeason: newSeason } : {}),
|
||||
// The ending series' guide moved onto its Finished Book; unless the launch brings a new one, clear it
|
||||
...(current.guide && !sched.newStudyGuideTitle?.trim()
|
||||
? {
|
||||
studyGuideTitle: '', studyGuideDescription: '', studyGuideDownloadUrl: '', studyGuideUrl: '',
|
||||
studyGuideAmazonButtonLabel: '', studyGuideAppleBooksUrl: '', studyGuideAppleBooksLabel: '',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const next = sanitizeSiteContent({
|
||||
...content,
|
||||
...lanePatch,
|
||||
studies,
|
||||
podcastFeaturedLinks: keptHighlights,
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
|
||||
...patch,
|
||||
})
|
||||
delete next[scheduleKey]
|
||||
return { next, archivedBook }
|
||||
}
|
||||
|
||||
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
|
||||
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
|
||||
// published content. Applies to both so a later publish doesn't undo it.
|
||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
||||
export async function runScheduledSeriesTransition() {
|
||||
const published = state.cachedSiteContent
|
||||
const draft = state.cachedDraftSiteContent
|
||||
if (!published) return
|
||||
|
||||
let nextPublished = published
|
||||
let nextDraft = draft
|
||||
const fired = []
|
||||
for (const { lane, scheduleKey } of SERIES_LANES) {
|
||||
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
|
||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
|
||||
|
||||
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
|
||||
const result = applySeriesTransition(nextPublished, sched, lane)
|
||||
nextPublished = result.next
|
||||
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
|
||||
fired.push(result.archivedBook?.title ?? '(nothing archived)')
|
||||
}
|
||||
if (fired.length === 0) return
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
try {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = nextPublished
|
||||
state.publishState.publishedAt = publishedAt
|
||||
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to write transition:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextDraft && nextDraft !== draft) {
|
||||
try {
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedDraftSiteContent = nextDraft
|
||||
state.publishState.draftUpdatedAt = publishedAt
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to update draft after transition:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-content', async (req, res) => {
|
||||
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
||||
@@ -64,7 +250,7 @@ export function register(app) {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||
if (source === 'published') {
|
||||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
||||
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
|
||||
res.json({ ...parsed, siteContent: safeSiteContent })
|
||||
return
|
||||
}
|
||||
@@ -91,25 +277,10 @@ export function register(app) {
|
||||
res.json(status)
|
||||
})
|
||||
|
||||
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||||
res.json({ checklist: state.podcastChecklist })
|
||||
})
|
||||
|
||||
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
||||
try {
|
||||
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
||||
state.podcastChecklist = safeChecklist
|
||||
await queuePodcastChecklistWrite()
|
||||
res.json({ ok: true, checklist: safeChecklist })
|
||||
} catch {
|
||||
res.status(500).json({ message: 'Failed to save podcast checklist.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/site-config', async (_req, res) => {
|
||||
try {
|
||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||
const siteContent = parsed.siteContent ?? {}
|
||||
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
|
||||
res.json({
|
||||
seo: siteContent.seo ?? DEFAULT_SEO,
|
||||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||||
@@ -227,9 +398,9 @@ export function register(app) {
|
||||
|
||||
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
|
||||
res.json({
|
||||
buildCommit: process.env.BUILD_COMMIT ?? null,
|
||||
buildCommit: process.env.BUILD_COMMIT ?? process.env.COMMIT_SHA ?? null,
|
||||
buildNumber: process.env.BUILD_NUMBER ?? null,
|
||||
deployedAt: process.env.DEPLOYED_AT ?? null,
|
||||
deployedAt: process.env.DEPLOYED_AT ?? state.serverStartedAt,
|
||||
cachePurge: state.lastCachePurgeStatus,
|
||||
deployHook: state.lastDeployHookStatus,
|
||||
})
|
||||
@@ -339,12 +510,18 @@ export function register(app) {
|
||||
res.json({ webhooks: state.webhooks })
|
||||
})
|
||||
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, async (req, res) => {
|
||||
const { url, events } = req.body ?? {}
|
||||
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
|
||||
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assertPublicHttpsUrl(url.trim())
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
|
||||
return
|
||||
}
|
||||
if (state.webhooks.length >= 50) {
|
||||
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
|
||||
return
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Resend } from 'resend'
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { buildSeasonCheck } from '../season-check.js'
|
||||
import { countPendingUploads } from './nuggets-workflow.js'
|
||||
import { state } from '../state.js'
|
||||
import { getStorageStatus, loadUserProgress } from '../data.js'
|
||||
import { getStudyCatalog } from '../study-helpers.js'
|
||||
import { resendRetry } from '../email.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
import { loadHistory } from './nuggets-host-upload.js'
|
||||
import { BACKUP_DIR } from '../config.js'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const STALE_FEED_DAYS = 21
|
||||
|
||||
function dayKey(offsetDays) {
|
||||
return new Date(Date.now() - offsetDays * DAY_MS).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function countSince(items, getDate, fromMs, toMs = Infinity) {
|
||||
let n = 0
|
||||
for (const item of items) {
|
||||
const t = new Date(getDate(item) ?? 0).getTime()
|
||||
if (t >= fromMs && t < toMs) n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function dailySeries(items, getDate, days) {
|
||||
const buckets = new Map()
|
||||
for (let i = days - 1; i >= 0; i--) buckets.set(dayKey(i), 0)
|
||||
for (const item of items) {
|
||||
const key = String(getDate(item) ?? '').slice(0, 10)
|
||||
if (buckets.has(key)) buckets.set(key, buckets.get(key) + 1)
|
||||
}
|
||||
return [...buckets.values()]
|
||||
}
|
||||
|
||||
function topCounts(values, limit) {
|
||||
const counts = {}
|
||||
for (const v of values) if (v) counts[v] = (counts[v] ?? 0) + 1
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, limit)
|
||||
}
|
||||
|
||||
function buildTrends() {
|
||||
const now = Date.now()
|
||||
const week = 7 * DAY_MS
|
||||
const subscribers = state.contactSubmissions.filter(s => s.subscribe === true)
|
||||
const studyUsers = state.studyUsers
|
||||
const realByDay = state.hitStats.byDayReal ?? {}
|
||||
const visitSeries = Array.from({ length: 14 }, (_, i) => realByDay[dayKey(13 - i)] ?? 0)
|
||||
return {
|
||||
visits: {
|
||||
thisWeek: visitSeries.slice(7).reduce((a, b) => a + b, 0),
|
||||
lastWeek: visitSeries.slice(0, 7).reduce((a, b) => a + b, 0),
|
||||
series: visitSeries,
|
||||
},
|
||||
subscribers: {
|
||||
thisWeek: countSince(subscribers, s => s.submittedAt, now - week),
|
||||
lastWeek: countSince(subscribers, s => s.submittedAt, now - 2 * week, now - week),
|
||||
series: dailySeries(subscribers, s => s.submittedAt, 14),
|
||||
},
|
||||
studySignups: {
|
||||
thisWeek: countSince(studyUsers, u => u.createdAt, now - week),
|
||||
lastWeek: countSince(studyUsers, u => u.createdAt, now - 2 * week, now - week),
|
||||
series: dailySeries(studyUsers, u => u.createdAt, 14),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopTraffic() {
|
||||
const cutoff = dayKey(6)
|
||||
const rows = (state.visitorStats.recentVisits ?? []).filter(r => !r.isBot && r.at && r.at >= cutoff)
|
||||
return {
|
||||
topPages: topCounts(rows.map(r => r.path ?? '/'), 5).map(([path, hits]) => ({ path, hits })),
|
||||
topReferrers: topCounts(rows.map(r => r.referrer), 5).map(([referrer, count]) => ({ referrer, count })),
|
||||
}
|
||||
}
|
||||
|
||||
function buildReengagement() {
|
||||
const now = Date.now()
|
||||
const tiers = { t1: 0, t2: 0, t3: 0 }
|
||||
let recentlySent = 0
|
||||
for (const user of state.studyUsers) {
|
||||
const sent = user.reengagementSentAt && typeof user.reengagementSentAt === 'object' ? user.reengagementSentAt : {}
|
||||
for (const at of Object.values(sent)) {
|
||||
if (now - new Date(at).getTime() <= 30 * DAY_MS) recentlySent++
|
||||
}
|
||||
if (!user.studyRemindersEnabled || !user.lastLoginAt || !(user.enrolledStudySlugs?.length)) continue
|
||||
const days = (now - new Date(user.lastLoginAt).getTime()) / DAY_MS
|
||||
if (days >= 30) tiers.t3++
|
||||
else if (days >= 14) tiers.t2++
|
||||
else if (days >= 7) tiers.t1++
|
||||
}
|
||||
return { ...tiers, recentlySent, remindersEnabled: state.studyUsers.filter(u => u.studyRemindersEnabled).length }
|
||||
}
|
||||
|
||||
async function buildStudyProgress() {
|
||||
const catalog = getStudyCatalog()
|
||||
const perStudy = new Map(catalog.map(s => [s.slug, { slug: s.slug, title: s.title, enrolled: 0, lessonsCompleted: 0, certificates: 0 }]))
|
||||
let lastActivityAt = null
|
||||
await Promise.all(state.studyUsers.map(async user => {
|
||||
for (const slug of user.enrolledStudySlugs ?? []) {
|
||||
const row = perStudy.get(slug)
|
||||
if (row) row.enrolled++
|
||||
}
|
||||
try {
|
||||
const progress = await loadUserProgress(user.id)
|
||||
for (const [slug, data] of Object.entries(progress.byStudy ?? {})) {
|
||||
const row = perStudy.get(slug)
|
||||
if (row) row.lessonsCompleted += (data.completedSectionIds ?? []).length
|
||||
}
|
||||
if (progress.updatedAt && (!lastActivityAt || progress.updatedAt > lastActivityAt)) lastActivityAt = progress.updatedAt
|
||||
} catch { /* ignore unreadable progress */ }
|
||||
}))
|
||||
for (const cert of state.studyCertificates ?? []) {
|
||||
const row = perStudy.get(cert.studySlug)
|
||||
if (row) row.certificates++
|
||||
}
|
||||
return {
|
||||
studies: [...perStudy.values()].filter(s => s.enrolled > 0 || s.lessonsCompleted > 0 || s.certificates > 0),
|
||||
certificatesTotal: (state.studyCertificates ?? []).length,
|
||||
lastActivityAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildLatestEpisode() {
|
||||
try {
|
||||
const episodes = await fetchAllEpisodes()
|
||||
const latest = episodes[0]
|
||||
if (!latest) return { available: false }
|
||||
const published = new Date(latest.pubDate)
|
||||
const valid = !isNaN(published.getTime())
|
||||
const daysAgo = valid ? Math.floor((Date.now() - published.getTime()) / DAY_MS) : null
|
||||
return {
|
||||
available: true,
|
||||
title: latest.title,
|
||||
episode: latest.episode,
|
||||
season: latest.season,
|
||||
publishedAt: valid ? published.toISOString() : null,
|
||||
daysAgo,
|
||||
stale: daysAgo !== null && daysAgo > STALE_FEED_DAYS,
|
||||
staleAfterDays: STALE_FEED_DAYS,
|
||||
}
|
||||
} catch {
|
||||
return { available: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function buildNuggets() {
|
||||
const { entries } = await loadHistory()
|
||||
const weekAgo = Date.now() - 7 * DAY_MS
|
||||
return {
|
||||
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
||||
pending: await countPendingUploads(),
|
||||
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
||||
entries: entries.slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHealth() {
|
||||
let backupCount = null
|
||||
try { backupCount = (await readdir(BACKUP_DIR)).filter(f => f.endsWith('.json')).length } catch { /* no backup dir yet */ }
|
||||
const storage = await getStorageStatus().catch(() => null)
|
||||
return {
|
||||
resendConfigured: Boolean(process.env.RESEND_API_KEY),
|
||||
lastBackup: state.lastBackupStatus,
|
||||
backupCount,
|
||||
dataDirWritable: storage ? storage.writable?.ok === true : null,
|
||||
writes: { hitStats: state.lastHitStatsWrite, visitorStats: state.lastVisitorStatsWrite },
|
||||
serverStartedAt: state.serverStartedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// The Resend SDK (6.x) exposes no open/click metrics for broadcasts, so this reports
|
||||
// the most recent send and links to the Resend dashboard for engagement numbers.
|
||||
async function buildLastBroadcast() {
|
||||
if (!process.env.RESEND_API_KEY) return { available: false, reason: 'RESEND_API_KEY is not configured.' }
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data: list, error } = await resendRetry(() => resend.broadcasts.list())
|
||||
if (error) throw new Error(error.message)
|
||||
const sent = (list?.data ?? []).filter(b => b.status === 'sent')
|
||||
.sort((a, b) => new Date(b.sent_at ?? 0).getTime() - new Date(a.sent_at ?? 0).getTime())[0]
|
||||
if (!sent) return { available: true, broadcast: null }
|
||||
const { data, error: getError } = await resendRetry(() => resend.broadcasts.get(sent.id))
|
||||
if (getError || !data) throw new Error(getError?.message ?? 'Broadcast not found.')
|
||||
return {
|
||||
available: true,
|
||||
broadcast: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subject: data.subject,
|
||||
sentAt: data.sent_at ?? sent.sent_at ?? null,
|
||||
dashboardUrl: `https://resend.com/broadcasts/${data.id}`,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return { available: false, reason: err instanceof Error ? err.message : 'Could not reach Resend.' }
|
||||
}
|
||||
}
|
||||
|
||||
function buildActivity(nuggetEntries) {
|
||||
const items = []
|
||||
for (const c of state.contactSubmissions) items.push({ at: c.submittedAt, kind: 'contact', text: `${c.name || c.email} sent a contact message` })
|
||||
for (const q of state.questions) items.push({ at: q.submittedAt, kind: 'question', text: `${q.firstName || 'Someone'} asked a question` })
|
||||
for (const c of state.studyComments) items.push({ at: c.createdAt, kind: 'comment', text: `${c.displayName || 'A student'} commented on a study` })
|
||||
for (const u of state.studyUsers) items.push({ at: u.createdAt, kind: 'study-user', text: `${u.displayName || u.username} created a study account` })
|
||||
for (const c of state.studyCertificates ?? []) items.push({ at: c.issuedAt, kind: 'certificate', text: `${c.displayName} earned a certificate: ${c.studyTitle}` })
|
||||
for (const e of nuggetEntries) items.push({ at: e.uploadedAt, kind: 'nuggets', text: `${e.hostName || 'A host'} uploaded "${e.episodeName}"` })
|
||||
return items
|
||||
.filter(i => i.at && !isNaN(new Date(i.at).getTime()))
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-dashboard', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const [studyProgress, latestEpisode, nuggets, health, lastBroadcast] = await Promise.all([
|
||||
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
||||
])
|
||||
const comments = state.studyComments ?? []
|
||||
const seasonCheck = await buildSeasonCheck()
|
||||
.then(c => ({
|
||||
total: c.total,
|
||||
errors: c.errors,
|
||||
warnings: c.warnings,
|
||||
general: c.general.length,
|
||||
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
|
||||
}))
|
||||
.catch(() => null)
|
||||
res.json({
|
||||
seasonCheck,
|
||||
trends: buildTrends(),
|
||||
traffic: buildTopTraffic(),
|
||||
reengagement: buildReengagement(),
|
||||
studyProgress,
|
||||
latestEpisode,
|
||||
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
|
||||
health,
|
||||
lastBroadcast,
|
||||
comments: {
|
||||
pending: comments.filter(c => c.isApproved !== true).length,
|
||||
thisWeek: countSince(comments, c => c.createdAt, Date.now() - 7 * DAY_MS),
|
||||
},
|
||||
activity: buildActivity(nuggets.entries),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-dashboard] failed:', err)
|
||||
res.status(500).json({ message: 'Failed to build dashboard data.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
+130
-44
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
|
||||
import { getStudyUserFromRequest } from '../study-helpers.js'
|
||||
@@ -19,15 +20,16 @@ import {
|
||||
buildLastNDaysStats,
|
||||
shouldCountHit,
|
||||
recordHit,
|
||||
isKnownSitePath,
|
||||
getStudyCatalog,
|
||||
} from '../study-helpers.js'
|
||||
import { getStudyCatalog } from '../study-helpers.js'
|
||||
|
||||
async function resolveGeo(ip) {
|
||||
async function resolveGeo(ip, ipHash) {
|
||||
if (!ip || isPrivateOrLocalIp(ip)) {
|
||||
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
|
||||
}
|
||||
|
||||
const cached = state.visitorStats.geoCacheByIp[ip]
|
||||
const cached = state.visitorStats.geoCacheByIp[ipHash]
|
||||
if (cached) return cached
|
||||
|
||||
const providers = [
|
||||
@@ -62,7 +64,7 @@ async function resolveGeo(ip) {
|
||||
try {
|
||||
const geo = await provider()
|
||||
if (geo) {
|
||||
state.visitorStats.geoCacheByIp[ip] = geo
|
||||
state.visitorStats.geoCacheByIp[ipHash] = geo
|
||||
queueVisitorStatsWrite()
|
||||
return geo
|
||||
}
|
||||
@@ -70,17 +72,44 @@ async function resolveGeo(ip) {
|
||||
}
|
||||
|
||||
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
|
||||
state.visitorStats.geoCacheByIp[ip] = fallback
|
||||
state.visitorStats.geoCacheByIp[ipHash] = fallback
|
||||
queueVisitorStatsWrite()
|
||||
return fallback
|
||||
}
|
||||
|
||||
export async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
|
||||
// Simple in-memory rate limiter for pageview endpoint.
|
||||
// This keys on req.ip, which collapses to one value for every visitor behind
|
||||
// a shared IP (a proxy in front of the app, a school/church network, CGNAT).
|
||||
// If TRUST_PROXY_HOPS is misconfigured for the real deployment topology,
|
||||
// req.ip silently becomes the proxy's IP for everyone and this limiter starts
|
||||
// dropping real traffic across all of them together. Log the first drop per
|
||||
// IP per window so that scenario shows up instead of vanishing silently.
|
||||
const pageviewHits = new Map()
|
||||
function pageviewAllowed(ip) {
|
||||
const now = Date.now()
|
||||
const windowMs = 60 * 1000
|
||||
const entry = pageviewHits.get(ip) ?? { count: 0, start: now, warned: false }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now; entry.warned = false }
|
||||
entry.count += 1
|
||||
pageviewHits.set(ip, entry)
|
||||
if (pageviewHits.size > 5000) {
|
||||
const cutoff = now - windowMs
|
||||
for (const [k, v] of pageviewHits) { if (v.start < cutoff) pageviewHits.delete(k) }
|
||||
}
|
||||
const allowed = entry.count <= 60
|
||||
if (!allowed && !entry.warned) {
|
||||
entry.warned = true
|
||||
console.warn(`[analytics] pageview rate limit hit for an IP (${entry.count} requests/min) — if this fires often, check TRUST_PROXY_HOPS against the real deployment topology, since a misconfigured hop count collapses many visitors onto one IP here`)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
export function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
let visitorId = cookies[VISITOR_COOKIE]
|
||||
const hadCookie = Boolean(visitorId)
|
||||
if (!visitorId) {
|
||||
visitorId = randomUUID()
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
@@ -95,12 +124,20 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
: null
|
||||
|
||||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||||
const geo = await resolveGeo(ip)
|
||||
|
||||
// Only fall back to IP-based correlation when the browser sent no cookie
|
||||
// at all — e.g. it never persisted (Safari ITP, a cookie-blocking
|
||||
// extension) — so that one real person doesn't get double-counted every
|
||||
// visit. If a cookie WAS sent, trust it: two different people sharing a
|
||||
// network (family wifi, a school or church LAN) must not be silently
|
||||
// merged into one visitor just because they share an IP, which would
|
||||
// corrupt page history and undercount unique visitors.
|
||||
if (!hadCookie) {
|
||||
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
|
||||
if (existingIdByIp && existingIdByIp !== visitorId) {
|
||||
if (existingIdByIp) {
|
||||
visitorId = existingIdByIp
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||
}
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
|
||||
}
|
||||
|
||||
const existingVisitor = state.visitorStats.visitors[visitorId]
|
||||
@@ -123,8 +160,12 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
knownIdentities.push(studyIdentity)
|
||||
}
|
||||
|
||||
// Use cached geo immediately; will be resolved async if not yet cached
|
||||
const cachedGeo = state.visitorStats.geoCacheByIp[ipHash]
|
||||
const geo = cachedGeo ?? { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
|
||||
|
||||
state.visitorStats.visitors[visitorId] = {
|
||||
visitorId, ip, ipHash,
|
||||
visitorId, ipHash,
|
||||
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
||||
lastSeenAt: nowIso,
|
||||
visitCount: nextVisitCount,
|
||||
@@ -141,7 +182,8 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
|
||||
state.visitorStats.lastVisitAt = nowIso
|
||||
state.visitorStats.recentVisits.unshift({
|
||||
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
|
||||
at: nowIso, visitorId, ipHash,
|
||||
path: pathKey, referrer, device,
|
||||
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
|
||||
returningVisitor: isReturning, visitCount: nextVisitCount,
|
||||
...(studyIdentity ? { studyUser: studyIdentity } : {}),
|
||||
@@ -149,9 +191,36 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
|
||||
|
||||
queueVisitorStatsWrite()
|
||||
|
||||
// Resolve geo in background if not already cached
|
||||
if (!cachedGeo) {
|
||||
resolveGeo(ip, ipHash).then(resolvedGeo => {
|
||||
if (state.visitorStats.visitors[visitorId]) {
|
||||
state.visitorStats.visitors[visitorId].location = resolvedGeo
|
||||
}
|
||||
const entry = state.visitorStats.recentVisits.find(v => v.visitorId === visitorId && v.at === nowIso)
|
||||
if (entry) {
|
||||
entry.country = resolvedGeo.country
|
||||
entry.state = resolvedGeo.state
|
||||
entry.county = resolvedGeo.county
|
||||
entry.city = resolvedGeo.city
|
||||
}
|
||||
queueVisitorStatsWrite()
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const analyticsWriteLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 120,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { ok: false, reason: 'rate-limited' },
|
||||
})
|
||||
|
||||
export function register(app) {
|
||||
app.use(['/api/analytics/play', '/api/analytics/event'], analyticsWriteLimiter)
|
||||
|
||||
app.post('/api/analytics-consent', (req, res) => {
|
||||
const consent = req.body?.consent === true
|
||||
setConsentCookie(res, consent)
|
||||
@@ -160,6 +229,10 @@ export function register(app) {
|
||||
|
||||
app.post('/api/analytics/play', (req, res) => {
|
||||
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
|
||||
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
|
||||
const ua = req.get('user-agent') ?? ''
|
||||
const { isBot } = detectBot(ua)
|
||||
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
|
||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : ''
|
||||
if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return }
|
||||
recordEpisodePlay(title)
|
||||
@@ -179,54 +252,62 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/analytics/pageview', async (req, res) => {
|
||||
if (isValidAdminSession(req)) {
|
||||
res.json({ ok: false, reason: 'admin' }); return
|
||||
}
|
||||
if (!hasVisitorConsent(req)) {
|
||||
res.json({ ok: false, reason: 'no-consent' }); return
|
||||
}
|
||||
app.post('/api/analytics/pageview', (req, res) => {
|
||||
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
|
||||
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
|
||||
const ua = req.get('user-agent') ?? ''
|
||||
const { isBot } = detectBot(ua)
|
||||
if (isBot) {
|
||||
res.json({ ok: false, reason: 'bot' }); return
|
||||
}
|
||||
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
|
||||
const ip = getClientIp(req)
|
||||
if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
|
||||
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
||||
if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return }
|
||||
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
||||
recordHit(rawPath, false)
|
||||
queueHitStatsWrite()
|
||||
await recordVisitor(req, res, rawPath, rawReferrer)
|
||||
recordVisitor(req, res, rawPath, rawReferrer)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
|
||||
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
|
||||
const rangeParam = req.query.range
|
||||
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30
|
||||
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d'
|
||||
const isAllTime = rangeParam === 'all'
|
||||
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : isAllTime ? null : 30
|
||||
const rangeLabel = rangeParam === '7d' ? 'Last 7 days' : rangeParam === '90d' ? 'Last 90 days' : isAllTime ? 'All time' : 'Last 30 days'
|
||||
|
||||
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets
|
||||
const cutoffDate = (() => {
|
||||
// Cutoff date (null = all-time)
|
||||
const cutoffDate = isAllTime ? null : (() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - (rangeDays - 1))
|
||||
d.setDate(d.getDate() - ((rangeDays ?? 30) - 1))
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
|
||||
// Filter byDayReal/byDayBot keys to only those within the range
|
||||
// All known day keys, sorted
|
||||
const allDayKeys = new Set([
|
||||
...Object.keys(state.hitStats.byDayReal ?? {}),
|
||||
...Object.keys(state.hitStats.byDayBot ?? {}),
|
||||
])
|
||||
const filteredDayKeys = [...allDayKeys].filter(day => day >= cutoffDate)
|
||||
const filteredDayKeys = isAllTime
|
||||
? [...allDayKeys].sort()
|
||||
: [...allDayKeys].filter(day => day >= cutoffDate).sort()
|
||||
|
||||
// Aggregate hits for the range
|
||||
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
|
||||
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
|
||||
const rangeTotalHits = rangeRealHits + rangeBotHits
|
||||
|
||||
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
|
||||
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
|
||||
if (!row.at) return false // exclude rows with no timestamp from range views
|
||||
// Trend chart data — one point per day for the selected range
|
||||
const trendData = filteredDayKeys.map(day => ({
|
||||
day,
|
||||
real: state.hitStats.byDayReal?.[day] ?? 0,
|
||||
bot: state.hitStats.byDayBot?.[day] ?? 0,
|
||||
}))
|
||||
|
||||
// Visitor rows for range
|
||||
const rangeVisitorRows = isAllTime
|
||||
? state.visitorStats.recentVisits
|
||||
: state.visitorStats.recentVisits.filter(row => {
|
||||
if (!row.at) return false
|
||||
return row.at >= cutoffDate
|
||||
})
|
||||
|
||||
@@ -257,9 +338,6 @@ export function register(app) {
|
||||
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
|
||||
|
||||
// All range rows used for geographic/device/referrer aggregates — no cap so no visits are invisible
|
||||
const allRangeRows = rangeVisitorRows
|
||||
|
||||
// Capped to 100 for the visitor log display (with page history hydrated)
|
||||
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
|
||||
const fullVisitor = state.visitorStats.visitors[row.visitorId]
|
||||
@@ -295,7 +373,6 @@ export function register(app) {
|
||||
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
|
||||
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
|
||||
|
||||
// Enrollment funnel: signups → first study visit → first section completed
|
||||
const funnelSignups = state.studyUsers.length
|
||||
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
|
||||
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
|
||||
@@ -307,6 +384,8 @@ export function register(app) {
|
||||
rangeTotalHits,
|
||||
rangeLabel,
|
||||
rangeDays,
|
||||
isAllTime,
|
||||
trendData,
|
||||
// All-time totals for reference
|
||||
allTimeRealHits: state.hitStats.realHits ?? 0,
|
||||
allTimeBotHits: state.hitStats.botHits ?? 0,
|
||||
@@ -314,6 +393,13 @@ export function register(app) {
|
||||
firstHitAt: state.hitStats.firstHitAt,
|
||||
lastHitAt: state.hitStats.lastHitAt,
|
||||
topPaths, topPathsReal, topPathsBot,
|
||||
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
|
||||
uniqueVisitors7d: new Set(
|
||||
state.visitorStats.recentVisits
|
||||
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
|
||||
.map(v => v.visitorId),
|
||||
).size,
|
||||
last7Days, last7DaysReal, last7DaysBot,
|
||||
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
|
||||
botReasons,
|
||||
@@ -323,13 +409,13 @@ export function register(app) {
|
||||
returningVisits: state.visitorStats.returningVisits,
|
||||
firstVisitAt: state.visitorStats.firstVisitAt,
|
||||
lastVisitAt: state.visitorStats.lastVisitAt,
|
||||
topCountries: buildTopLocations(allRangeRows, 'country'),
|
||||
topStates: buildTopLocations(allRangeRows, 'state'),
|
||||
topCounties: buildTopLocations(allRangeRows, 'county'),
|
||||
topCities: buildTopLocations(allRangeRows, 'city'),
|
||||
topCountries: buildTopLocations(rangeVisitorRows, 'country'),
|
||||
topStates: buildTopLocations(rangeVisitorRows, 'state'),
|
||||
topCounties: buildTopLocations(rangeVisitorRows, 'county'),
|
||||
topCities: buildTopLocations(rangeVisitorRows, 'city'),
|
||||
deviceBreakdown: (() => {
|
||||
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||||
for (const row of allRangeRows) {
|
||||
for (const row of rangeVisitorRows) {
|
||||
const d = row.device ?? 'unknown'
|
||||
counts[d] = (counts[d] ?? 0) + 1
|
||||
}
|
||||
@@ -337,7 +423,7 @@ export function register(app) {
|
||||
})(),
|
||||
topReferrers: (() => {
|
||||
const counts = {}
|
||||
for (const row of allRangeRows) {
|
||||
for (const row of rangeVisitorRows) {
|
||||
if (!row.referrer) continue
|
||||
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
||||
|
||||
const passageCache = new Map()
|
||||
const PASSAGE_CACHE_MAX = 2000
|
||||
const MAX_CHAPTER = 150
|
||||
const MAX_VERSE = 176
|
||||
const MAX_VERSES_PER_REQUEST = 60
|
||||
|
||||
const passageRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many passage requests. Please slow down.' },
|
||||
})
|
||||
|
||||
// Keyed by lowercase, whitespace-stripped book name/alias (matching the format
|
||||
// the admin's "Bible Book" field and the /api/bible-passage `book` query param
|
||||
@@ -16,7 +29,12 @@ BIBLE_BOOKS.forEach((b, index) => {
|
||||
})
|
||||
|
||||
function parseReference(reference) {
|
||||
const stripped = reference.replace(/^[a-z]+/i, '')
|
||||
// Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a")
|
||||
const stripped = reference
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/(\d)[a-z]\b/gi, '$1')
|
||||
.replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '')
|
||||
.trim()
|
||||
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
|
||||
if (!match) return null
|
||||
return {
|
||||
@@ -27,7 +45,7 @@ function parseReference(reference) {
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/bible-passage', async (req, res) => {
|
||||
app.get('/api/bible-passage', passageRateLimiter, async (req, res) => {
|
||||
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
|
||||
const reference = String(req.query.reference ?? '').trim()
|
||||
|
||||
@@ -43,6 +61,16 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER
|
||||
|| parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE
|
||||
|| parsed.verseEnd < parsed.verseStart
|
||||
|| parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST
|
||||
) {
|
||||
res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` })
|
||||
return
|
||||
}
|
||||
|
||||
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
|
||||
if (passageCache.has(cacheKey)) {
|
||||
res.json({ text: passageCache.get(cacheKey) })
|
||||
@@ -66,6 +94,7 @@ export function register(app) {
|
||||
}
|
||||
|
||||
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
|
||||
if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value)
|
||||
passageCache.set(cacheKey, text)
|
||||
res.json({ text })
|
||||
} catch {
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { state } from '../state.js'
|
||||
import { queueCalendarEventsWrite } from '../data.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
|
||||
const VALID_TYPES = ['general', 'recording', 'social', 'task']
|
||||
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const TIME_RE = /^\d{2}:\d{2}$/
|
||||
|
||||
function sanitizeRecurrence(raw) {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
|
||||
if (freq === 'none') return null
|
||||
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
|
||||
return { freq, until }
|
||||
}
|
||||
|
||||
function sanitize(ev) {
|
||||
const recurrence = sanitizeRecurrence(ev.recurrence)
|
||||
return {
|
||||
id: ev.id,
|
||||
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
|
||||
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
|
||||
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
|
||||
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
|
||||
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
|
||||
completed: Boolean(ev.completed),
|
||||
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
|
||||
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
|
||||
recurrence: recurrence ?? undefined,
|
||||
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── iCal helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeIcs(s) {
|
||||
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
function foldIcsLine(line) {
|
||||
const chars = [...line]
|
||||
if (chars.length <= 75) return line
|
||||
const parts = []
|
||||
let current = ''
|
||||
for (const ch of chars) {
|
||||
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
|
||||
else current += ch
|
||||
}
|
||||
if (current) parts.push(current)
|
||||
return parts.join('\r\n')
|
||||
}
|
||||
|
||||
function toIcsDatetime(dateStr, timeStr) {
|
||||
const d = dateStr.replace(/-/g, '')
|
||||
if (timeStr && TIME_RE.test(timeStr)) {
|
||||
const t = timeStr.replace(':', '') + '00'
|
||||
return `DTSTART:${d}T${t}`
|
||||
}
|
||||
return `DTSTART;VALUE=DATE:${d}`
|
||||
}
|
||||
|
||||
function toIcsTimestamp(iso) {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function rruleFor(recurrence) {
|
||||
if (!recurrence || recurrence.freq === 'none') return null
|
||||
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
|
||||
const freq = freqMap[recurrence.freq]
|
||||
if (!freq) return null
|
||||
let rule = `RRULE:FREQ=${freq}`
|
||||
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
|
||||
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
|
||||
return rule
|
||||
}
|
||||
|
||||
function generateIcal() {
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Siteforge//CalendarExport//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'X-WR-CALNAME:Verse by Verse Calendar',
|
||||
]
|
||||
|
||||
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
|
||||
if (!ep.datePublished) continue
|
||||
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
|
||||
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:ep-${ep.id}@siteforge`)
|
||||
lines.push(dtstart)
|
||||
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
|
||||
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
|
||||
lines.push('END:VEVENT')
|
||||
}
|
||||
|
||||
for (const ev of (state.calendarEvents ?? [])) {
|
||||
if (!ev.date) continue
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:ev-${ev.id}@siteforge`)
|
||||
lines.push(toIcsDatetime(ev.date, ev.startTime))
|
||||
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
|
||||
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
|
||||
const rrule = rruleFor(ev.recurrence)
|
||||
if (rrule) lines.push(rrule)
|
||||
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
|
||||
lines.push('END:VEVENT')
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR')
|
||||
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
// ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
|
||||
res.json({ events: state.calendarEvents })
|
||||
})
|
||||
|
||||
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
|
||||
const ical = generateIcal()
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
|
||||
res.send(ical)
|
||||
})
|
||||
|
||||
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
|
||||
const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {}
|
||||
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
|
||||
if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return }
|
||||
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
|
||||
|
||||
const ev = sanitize({
|
||||
id: randomUUID(),
|
||||
type: type ?? 'general',
|
||||
title: String(title).trim(),
|
||||
date,
|
||||
startTime: startTime ?? undefined,
|
||||
notes: notes ?? '',
|
||||
completed: false,
|
||||
reminderDays: reminderDays ?? 0,
|
||||
recurrence: recurrence ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
state.calendarEvents.unshift(ev)
|
||||
queueCalendarEventsWrite()
|
||||
res.json({ ok: true, event: ev })
|
||||
})
|
||||
|
||||
app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
let found = false
|
||||
state.calendarEvents = state.calendarEvents.map(ev => {
|
||||
if (ev.id !== id) return ev
|
||||
found = true
|
||||
const patch = {}
|
||||
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
|
||||
if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
|
||||
if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
|
||||
if (req.body?.startTime === null) patch.startTime = undefined
|
||||
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
|
||||
if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
|
||||
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
|
||||
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
|
||||
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
|
||||
const dateChanged = patch.date && patch.date !== ev.date
|
||||
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
|
||||
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
|
||||
return { ...ev, ...patch }
|
||||
})
|
||||
if (!found) { res.status(404).json({ message: 'Event not found.' }); return }
|
||||
queueCalendarEventsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
|
||||
const before = state.calendarEvents.length
|
||||
state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id)
|
||||
if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return }
|
||||
queueCalendarEventsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
+400
-40
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { Resend } from 'resend'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { escapeHtml, splitName } from '../helpers.js'
|
||||
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
|
||||
import {
|
||||
MAX_CONTACT_SUBMISSIONS,
|
||||
MAX_QUESTIONS,
|
||||
USE_RESEND_AUTOMATION_WELCOME,
|
||||
DEFAULT_SEO,
|
||||
DATA_FILE,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
fireWebhooks,
|
||||
loadSiteContentFile,
|
||||
} from '../data.js'
|
||||
import {
|
||||
noteContactEmailCooldown,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
buildContactAdminNotificationTemplate,
|
||||
sendResendEmailWithRetry,
|
||||
syncContactToResend,
|
||||
resendRetry,
|
||||
} from '../email.js'
|
||||
|
||||
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
||||
@@ -130,7 +133,6 @@ export function register(app) {
|
||||
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
|
||||
|
||||
const wantsWelcome = subscribe === true
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
@@ -138,9 +140,9 @@ export function register(app) {
|
||||
email: trimmedEmail,
|
||||
message: trimmedMessage,
|
||||
messageType: normalizedMessageType,
|
||||
subscribe: wantsWelcome,
|
||||
subscribe: shouldSendWelcome,
|
||||
archived: false,
|
||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||
emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
@@ -187,8 +189,6 @@ export function register(app) {
|
||||
let publishedSiteContent = state.cachedSiteContent
|
||||
if (!publishedSiteContent) {
|
||||
try {
|
||||
const { loadSiteContentFile } = await import('../data.js')
|
||||
const { DATA_FILE } = await import('../config.js')
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
publishedSiteContent = published?.siteContent ?? null
|
||||
} catch { publishedSiteContent = null }
|
||||
@@ -200,7 +200,6 @@ export function register(app) {
|
||||
: DEFAULT_SEO.canonicalUrl
|
||||
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
||||
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
||||
const { buildAbsoluteUrl } = await import('../helpers.js')
|
||||
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
||||
greetingName,
|
||||
welcomeGreetingPrefix,
|
||||
@@ -312,7 +311,9 @@ export function register(app) {
|
||||
|| String(req.query?.token || '').trim()
|
||||
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
|
||||
|
||||
if (!providedToken || providedToken !== expectedToken) {
|
||||
const tokenA = Buffer.from(providedToken, 'utf8')
|
||||
const tokenB = Buffer.from(expectedToken, 'utf8')
|
||||
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
|
||||
res.status(401).json({ message: 'Unauthorized webhook.' }); return
|
||||
}
|
||||
|
||||
@@ -355,7 +356,50 @@ export function register(app) {
|
||||
|
||||
|
||||
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
|
||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||
res.json({ submissions: state.contactSubmissions })
|
||||
})
|
||||
|
||||
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
const { archived, read } = req.body ?? {}
|
||||
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
|
||||
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
|
||||
}
|
||||
const idx = state.contactSubmissions.findIndex(s => s.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Submission not found.' }); return
|
||||
}
|
||||
const patch = {}
|
||||
if (typeof archived === 'boolean') patch.archived = archived
|
||||
if (typeof read === 'boolean') patch.read = read
|
||||
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, submission: state.contactSubmissions[idx] })
|
||||
})
|
||||
|
||||
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
|
||||
const { ids, action } = req.body ?? {}
|
||||
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
|
||||
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
|
||||
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
|
||||
}
|
||||
const idSet = new Set(ids)
|
||||
let count = 0
|
||||
if (action === 'delete') {
|
||||
const before = state.contactSubmissions.length
|
||||
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
|
||||
count = before - state.contactSubmissions.length
|
||||
} else {
|
||||
state.contactSubmissions = state.contactSubmissions.map(s => {
|
||||
if (!idSet.has(s.id)) return s
|
||||
count += 1
|
||||
if (action === 'archive') return { ...s, archived: true }
|
||||
if (action === 'unarchive') return { ...s, archived: false }
|
||||
return { ...s, read: action === 'read' }
|
||||
})
|
||||
}
|
||||
queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, count })
|
||||
})
|
||||
|
||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||
@@ -366,7 +410,7 @@ export function register(app) {
|
||||
name: entry.name,
|
||||
email: entry.email,
|
||||
subscribedAt: entry.submittedAt,
|
||||
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||||
source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
|
||||
}))
|
||||
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
|
||||
res.json({ subscribers, total: subscribers.length })
|
||||
@@ -388,8 +432,311 @@ export function register(app) {
|
||||
res.send(csv)
|
||||
})
|
||||
|
||||
// ── Subscribers ⇄ Resend comparison ─────────────────────────────────────────
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function getAppSubscribers() {
|
||||
const byEmail = new Map()
|
||||
for (const entry of state.contactSubmissions) {
|
||||
if (entry.subscribe !== true || !entry.email) continue
|
||||
const email = String(entry.email).trim().toLowerCase()
|
||||
if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email })
|
||||
}
|
||||
return byEmail
|
||||
}
|
||||
|
||||
async function listAllResendContacts(resend, segmentId) {
|
||||
const out = []
|
||||
let after
|
||||
for (let page = 0; page < 50; page += 1) {
|
||||
const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }))
|
||||
if (error) throw new Error(error.message || 'Resend contacts.list failed')
|
||||
const rows = data?.data ?? []
|
||||
out.push(...rows)
|
||||
if (!data?.has_more || rows.length === 0) break
|
||||
after = rows[rows.length - 1].id
|
||||
await sleep(550) // Resend allows ~2 requests/second
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function buildResendComparison() {
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID || null
|
||||
const appSubs = getAppSubscribers()
|
||||
const resendContacts = await listAllResendContacts(resend)
|
||||
let segmentEmails = null
|
||||
if (segmentId) {
|
||||
await sleep(550)
|
||||
segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase()))
|
||||
}
|
||||
|
||||
const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c]))
|
||||
const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ')
|
||||
|
||||
const onlyInApp = []
|
||||
const unsubscribedInResend = []
|
||||
const notInSegment = []
|
||||
let matched = 0
|
||||
for (const sub of appSubs.values()) {
|
||||
const contact = resendByEmail.get(sub.email)
|
||||
if (!contact) { onlyInApp.push(sub); continue }
|
||||
if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue }
|
||||
if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue }
|
||||
matched += 1
|
||||
}
|
||||
const onlyInResend = resendContacts
|
||||
.filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed)
|
||||
.map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() }))
|
||||
|
||||
return {
|
||||
appCount: appSubs.size,
|
||||
resendCount: resendContacts.length,
|
||||
resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length,
|
||||
segmentConfigured: Boolean(segmentId),
|
||||
segmentCount: segmentEmails ? segmentEmails.size : null,
|
||||
matched,
|
||||
onlyInApp,
|
||||
onlyInResend,
|
||||
unsubscribedInResend,
|
||||
notInSegment,
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
res.json(await buildResendComparison())
|
||||
} catch (err) {
|
||||
console.error('[subscribers] resend compare failed:', err)
|
||||
res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => {
|
||||
const { action } = req.body ?? {}
|
||||
if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) {
|
||||
res.status(400).json({ message: 'Unknown action.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const diff = await buildResendComparison()
|
||||
|
||||
if (action === 'apply-unsubscribes') {
|
||||
const emails = new Set(diff.unsubscribedInResend.map(s => s.email))
|
||||
let changed = 0
|
||||
state.contactSubmissions = state.contactSubmissions.map(entry => {
|
||||
if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) {
|
||||
changed += 1
|
||||
return { ...entry, subscribe: false }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
if (changed > 0) queueContactSubmissionsWrite()
|
||||
res.json({ ok: true, done: emails.size, failed: 0 }); return
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID || null
|
||||
const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100)
|
||||
if (action === 'add-to-segment' && !segmentId) {
|
||||
res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return
|
||||
}
|
||||
|
||||
let done = 0
|
||||
let failed = 0
|
||||
for (const target of targets) {
|
||||
try {
|
||||
if (action === 'add-to-resend') {
|
||||
const { firstName, lastName } = splitName(target.name)
|
||||
const { error } = await resendRetry(() => resend.contacts.create({
|
||||
email: target.email, firstName, lastName, unsubscribed: false,
|
||||
...(segmentId ? { segments: [{ id: segmentId }] } : {}),
|
||||
}))
|
||||
if (error) throw new Error(error.message)
|
||||
} else {
|
||||
const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId }))
|
||||
if (error) throw new Error(error.message)
|
||||
}
|
||||
done += 1
|
||||
} catch (err) {
|
||||
failed += 1
|
||||
console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err)
|
||||
}
|
||||
await sleep(550)
|
||||
}
|
||||
const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length
|
||||
res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) })
|
||||
} catch (err) {
|
||||
console.error('[subscribers] resend sync failed:', err)
|
||||
res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const templates = []
|
||||
let after
|
||||
for (let page = 0; page < 5; page += 1) {
|
||||
const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) }))
|
||||
if (error) throw new Error(error.message)
|
||||
templates.push(...(data?.data ?? []))
|
||||
if (!data?.has_more || !data.data?.length) break
|
||||
after = data.data[data.data.length - 1].id
|
||||
}
|
||||
res.json({
|
||||
templates: templates
|
||||
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
|
||||
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] template list failed:', err)
|
||||
res.status(502).json({ message: 'Could not load templates from Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data, error } = await resendRetry(() => resend.templates.get(req.params.id))
|
||||
if (error || !data) {
|
||||
res.status(404).json({ message: error?.message || 'Template not found.' }); return
|
||||
}
|
||||
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
|
||||
let html = data.html ?? ''
|
||||
for (const v of data.variables ?? []) {
|
||||
if (/^(first_?name|name)$/i.test(v.key)) continue
|
||||
if (v.fallback_value === null || v.fallback_value === undefined) continue
|
||||
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
|
||||
}
|
||||
res.json({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subject: data.subject ?? '',
|
||||
html,
|
||||
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
|
||||
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
|
||||
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] template get failed:', err)
|
||||
res.status(502).json({ message: 'Could not load that template from Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
|
||||
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
|
||||
const { emails } = req.body ?? {}
|
||||
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
|
||||
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const diff = await buildResendComparison()
|
||||
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
|
||||
const now = new Date().toISOString()
|
||||
const created = []
|
||||
for (const raw of emails) {
|
||||
const email = raw.trim().toLowerCase()
|
||||
const contact = allowed.get(email)
|
||||
if (!contact) continue // not active in Resend, or already a subscriber here
|
||||
created.push({
|
||||
id: randomUUID(),
|
||||
submittedAt: now,
|
||||
name: contact.name || email,
|
||||
email,
|
||||
message: 'Imported from Resend audience',
|
||||
messageType: 'general',
|
||||
subscribe: true,
|
||||
archived: true,
|
||||
read: true,
|
||||
source: 'resend-import',
|
||||
})
|
||||
}
|
||||
if (created.length > 0) {
|
||||
state.contactSubmissions = [...created, ...state.contactSubmissions]
|
||||
queueContactSubmissionsWrite()
|
||||
}
|
||||
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
|
||||
} catch (err) {
|
||||
console.error('[subscribers] import from resend failed:', err)
|
||||
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
|
||||
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
|
||||
|
||||
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
|
||||
function toResendBroadcastHtml(html) {
|
||||
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
|
||||
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
|
||||
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID
|
||||
if (!process.env.RESEND_API_KEY || !segmentId) {
|
||||
res.json({ segmentConfigured: false, count: null }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
|
||||
const contacts = await listAllResendContacts(resend, segmentId)
|
||||
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] audience count failed:', err)
|
||||
res.status(502).json({ message: 'Could not read the Resend segment.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html, to } = req.body ?? {}
|
||||
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
|
||||
res.status(400).json({ message: 'Subject and body are required.' }); return
|
||||
}
|
||||
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
|
||||
res.status(400).json({ message: 'Enter a valid test email address.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const preview = toResendBroadcastHtml(html)
|
||||
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
|
||||
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { error } = await resend.emails.send({
|
||||
from: getResendFromAddress(),
|
||||
to: to.trim(),
|
||||
replyTo: getResendReplyToAddress(),
|
||||
subject: `[TEST] ${subject.trim()}`,
|
||||
html: preview,
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] test send failed:', err)
|
||||
res.status(502).json({ message: 'Test email failed to send.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html } = req.body ?? {}
|
||||
const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
|
||||
if (!subject || typeof subject !== 'string' || !subject.trim()) {
|
||||
res.status(400).json({ message: 'Subject is required.' }); return
|
||||
}
|
||||
@@ -399,38 +746,51 @@ export function register(app) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
const recipients = state.contactSubmissions
|
||||
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email))
|
||||
.map(e => ({ name: e.name ?? '', email: e.email }))
|
||||
|
||||
if (recipients.length === 0) {
|
||||
res.status(400).json({ message: 'No subscribers to send to.' }); return
|
||||
const segmentId = process.env.RESEND_SEGMENT_ID
|
||||
if (!segmentId) {
|
||||
res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
|
||||
}
|
||||
let scheduled
|
||||
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
|
||||
const when = Date.parse(scheduledAt)
|
||||
if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
|
||||
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
|
||||
}
|
||||
scheduled = new Date(when).toISOString()
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = getResendFromAddress()
|
||||
const replyTo = getResendReplyToAddress()
|
||||
const BATCH = 50
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (let i = 0; i < recipients.length; i += BATCH) {
|
||||
const batch = recipients.slice(i, i + BATCH)
|
||||
const emails = batch.map(r => ({
|
||||
from,
|
||||
to: r.email,
|
||||
reply_to: replyTo,
|
||||
subject: subject.trim(),
|
||||
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')),
|
||||
}))
|
||||
try {
|
||||
const { error } = await resend.batch.send(emails)
|
||||
if (error) { failed += batch.length } else { sent += batch.length }
|
||||
} catch { failed += batch.length }
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
let topicId
|
||||
if (tagNewEpisode === true) {
|
||||
const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list())
|
||||
if (topicsError) throw new Error(topicsError.message)
|
||||
topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
|
||||
if (!topicId) {
|
||||
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, sent, failed, total: recipients.length })
|
||||
const { data, error } = await resendRetry(() => resend.broadcasts.create({
|
||||
name: subject.trim().slice(0, 120),
|
||||
segmentId,
|
||||
from: getResendFromAddress(),
|
||||
replyTo: getResendReplyToAddress(),
|
||||
subject: subject.trim(),
|
||||
html: toResendBroadcastHtml(html),
|
||||
...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
|
||||
...(topicId ? { topicId } : {}),
|
||||
send: true,
|
||||
...(scheduled ? { scheduledAt: scheduled } : {}),
|
||||
}))
|
||||
if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
|
||||
|
||||
console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
|
||||
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
|
||||
} catch (err) {
|
||||
console.error('[admin-broadcast] send failed:', err)
|
||||
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -145,6 +145,18 @@ export function register(app) {
|
||||
const customResources = Array.isArray(siteContent.customLinks)
|
||||
? siteContent.customLinks.filter(link => link?.placement === 'resources')
|
||||
: []
|
||||
// Companion study guides: the second series' guide, and guides kept on finished books
|
||||
if (id === 'guide:second') {
|
||||
const guide = siteContent.secondSeries?.guide
|
||||
const guideUrl = sanitizeUrl(guide?.downloadUrl)
|
||||
return guideUrl ? { label: guide.title, url: guideUrl } : null
|
||||
}
|
||||
if (id.startsWith('guide:book:')) {
|
||||
const bookId = id.slice('guide:book:'.length)
|
||||
const book = Array.isArray(siteContent.finishedBooks) ? siteContent.finishedBooks.find(item => item?.id === bookId) : null
|
||||
const bookGuideUrl = sanitizeUrl(book?.guide?.downloadUrl)
|
||||
return bookGuideUrl ? { label: book.guide.title, url: bookGuideUrl } : null
|
||||
}
|
||||
if (id.startsWith('custom:')) {
|
||||
const customId = id.slice('custom:'.length)
|
||||
const match = customResources.find(link => link.id === customId)
|
||||
@@ -210,6 +222,81 @@ export function register(app) {
|
||||
}
|
||||
})
|
||||
|
||||
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
|
||||
// The destination always comes from the saved site content, never from the request.
|
||||
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
|
||||
try {
|
||||
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
|
||||
res.status(400).json({ message: 'That link is not available.' }); return
|
||||
}
|
||||
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'First name is required.' }); return
|
||||
}
|
||||
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||||
res.status(400).json({ message: 'Last name is required.' }); return
|
||||
}
|
||||
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
||||
res.status(400).json({ message: 'A valid email address is required.' }); return
|
||||
}
|
||||
|
||||
const published = await loadSiteContentFile(DATA_FILE)
|
||||
const content = published?.siteContent ?? {}
|
||||
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
|
||||
let label = ''
|
||||
let url = ''
|
||||
|
||||
if (source === 'page:primary-guide') {
|
||||
label = content.studyGuideTitle
|
||||
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
|
||||
} else if (source === 'page:second-guide') {
|
||||
const guide = content.secondSeries?.guide
|
||||
label = guide?.title
|
||||
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||
} else if (source.startsWith('page:book-guide--')) {
|
||||
const id = source.slice('page:book-guide--'.length)
|
||||
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
|
||||
label = guide?.title
|
||||
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||
} else if (source.startsWith('page:custom--')) {
|
||||
const id = source.slice('page:custom--'.length)
|
||||
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
|
||||
label = link?.label
|
||||
url = pick(link?.amazonUrl, link?.appleBooksUrl)
|
||||
} else if (source.startsWith('book:')) {
|
||||
const id = source.slice('book:'.length)
|
||||
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
|
||||
label = book?.label
|
||||
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
|
||||
}
|
||||
|
||||
const destination = sanitizeUrl(url)
|
||||
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
|
||||
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
const wantsSubscribe = subscribe !== false
|
||||
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||
|
||||
addContactSubmission({
|
||||
name,
|
||||
email: trimmedEmail,
|
||||
message: `Requested ${storeName} link: ${label || source}`,
|
||||
messageType: 'general',
|
||||
subscribe: wantsSubscribe,
|
||||
})
|
||||
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
|
||||
incrementDownloadCount(`store:${source}:${store}`)
|
||||
|
||||
res.json({ ok: true, url: destination })
|
||||
} catch (err) {
|
||||
console.error('[store-link] request error:', err)
|
||||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||||
res.json({ counts: state.downloadCounts })
|
||||
})
|
||||
|
||||
@@ -3,28 +3,69 @@ import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueEpisodeScriptsWrite } from '../data.js'
|
||||
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
// Word-boundary containment so "sin" doesn't match inside "using"/"resin".
|
||||
function containsWord(haystackLower, word) {
|
||||
return new RegExp(`\\b${escapeRegExp(word)}\\b`, 'i').test(haystackLower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple whitespace-normalised search over episode script text.
|
||||
* Returns { episodeNumber, title, filename, snippet, score } for matches.
|
||||
* Whitespace-normalised search over episode script text, with word-boundary
|
||||
* matching, an exact-phrase score boost, and a "season N" browse mode.
|
||||
* Returns { episodeNumber, title, filename, season, snippet, score } for matches.
|
||||
*/
|
||||
function searchScripts(query) {
|
||||
if (!query || query.trim().length < 2) return []
|
||||
const q = query.trim().toLowerCase()
|
||||
const words = q.split(/\s+/).filter(w => w.length >= 2)
|
||||
const q = query.trim()
|
||||
const qLower = q.toLowerCase()
|
||||
|
||||
// "season N" browses every script tagged with that season, no text match needed.
|
||||
const seasonQuery = /^season\s+(\d+)$/i.exec(q)
|
||||
if (seasonQuery) {
|
||||
const season = seasonQuery[1]
|
||||
return Object.entries(state.episodeScripts)
|
||||
.filter(([, entry]) => String(entry.season ?? '') === season)
|
||||
.map(([key, entry]) => {
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
return {
|
||||
key,
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
snippet: '',
|
||||
score: 1,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
const words = qLower.split(/\s+/).filter(w => w.length >= 2)
|
||||
if (words.length === 0) return []
|
||||
const results = []
|
||||
|
||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||
for (const [key, entry] of Object.entries(state.episodeScripts)) {
|
||||
if (!entry?.text) continue
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const text = entry.text.toLowerCase()
|
||||
|
||||
// Count how many words appear in the text
|
||||
const matchCount = words.filter(w => text.includes(w)).length
|
||||
if (matchCount === 0) continue
|
||||
const matchedWords = words.filter(w => containsWord(text, w))
|
||||
if (matchedWords.length === 0) continue
|
||||
|
||||
// Exact multi-word phrase matches rank above partial word-overlap matches.
|
||||
const isExactPhrase = words.length > 1 && text.includes(qLower)
|
||||
const score = isExactPhrase ? 1 : matchedWords.length / words.length
|
||||
|
||||
// Find a snippet around the first matching word
|
||||
const firstWord = words.find(w => text.includes(w))
|
||||
const idx = firstWord ? text.indexOf(firstWord) : 0
|
||||
const firstWord = matchedWords[0]
|
||||
const idx = text.indexOf(firstWord)
|
||||
const start = Math.max(0, idx - 80)
|
||||
const end = Math.min(entry.text.length, idx + 180)
|
||||
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
@@ -32,11 +73,13 @@ function searchScripts(query) {
|
||||
if (end < entry.text.length) snippet = snippet + '…'
|
||||
|
||||
results.push({
|
||||
key,
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
snippet,
|
||||
score: matchCount / words.length, // 0–1: fraction of query words found
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,19 +96,23 @@ export function register(app) {
|
||||
|
||||
// Admin: list all uploaded scripts
|
||||
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
|
||||
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
|
||||
episodeNumber,
|
||||
const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
|
||||
key,
|
||||
episodeNumber: scriptEpisodeNumber(key, entry),
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
uploadedAt: entry.uploadedAt ?? null,
|
||||
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
|
||||
}))
|
||||
list.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
res.json({ scripts: list })
|
||||
})
|
||||
|
||||
// Admin: upload a docx script for an episode
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => {
|
||||
// "backfill-seasons" is its own route below; don't let this one swallow it
|
||||
if (req.params.episodeNumber === 'backfill-seasons') { next(); return }
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
|
||||
@@ -74,6 +121,8 @@ export function register(app) {
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
|
||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
|
||||
const seasonRaw = typeof req.body?.season === 'string' ? req.body.season.trim() : (typeof req.body?.season === 'number' ? String(req.body.season) : '')
|
||||
const season = /^\d+$/.test(seasonRaw) ? seasonRaw : null
|
||||
|
||||
if (!dataUrl.startsWith('data:')) {
|
||||
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
|
||||
@@ -105,9 +154,16 @@ export function register(app) {
|
||||
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
|
||||
}
|
||||
|
||||
state.episodeScripts[episodeNumber] = {
|
||||
const key = scriptKey(season, episodeNumber)
|
||||
// A script with no season that this upload replaces would otherwise linger as a duplicate
|
||||
if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) {
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
}
|
||||
state.episodeScripts[key] = {
|
||||
episodeNumber,
|
||||
title,
|
||||
filename,
|
||||
season,
|
||||
text,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
wordCount: text.split(/\s+/).filter(Boolean).length,
|
||||
@@ -116,18 +172,69 @@ export function register(app) {
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
key,
|
||||
episodeNumber,
|
||||
wordCount: state.episodeScripts[episodeNumber].wordCount,
|
||||
season,
|
||||
wordCount: state.episodeScripts[key].wordCount,
|
||||
})
|
||||
})
|
||||
|
||||
// Admin: fill in season numbers for existing scripts from the podcast RSS feed
|
||||
// (matches by episode number; never overwrites a season that's already set)
|
||||
app.post('/api/admin-episode-scripts/backfill-seasons', requireAdminAuth, async (_req, res) => {
|
||||
let episodes
|
||||
try {
|
||||
episodes = await fetchAllEpisodes()
|
||||
} catch (err) {
|
||||
console.error('[episode-scripts] backfill: RSS fetch failed:', err.message)
|
||||
res.status(502).json({ message: 'Could not fetch the podcast RSS feed to look up seasons.' })
|
||||
return
|
||||
}
|
||||
|
||||
const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
// Episode numbers can repeat across seasons, so keep every season an episode number appears in.
|
||||
const feedByEpisodeNumber = new Map()
|
||||
for (const ep of episodes) {
|
||||
if (!ep.episode || !ep.season) continue
|
||||
const list = feedByEpisodeNumber.get(ep.episode) ?? []
|
||||
list.push({ season: String(ep.season), title: normalize(ep.title) })
|
||||
feedByEpisodeNumber.set(ep.episode, list)
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
const stillMissing = []
|
||||
for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
|
||||
if (entry.season) continue
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
|
||||
let match = candidates.length === 1 ? candidates[0] : undefined
|
||||
// Several seasons share this episode number: fall back to matching the title
|
||||
if (!match && candidates.length > 1) {
|
||||
const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title))
|
||||
if (byTitle.length === 1) match = byTitle[0]
|
||||
}
|
||||
const newKey = match ? scriptKey(match.season, episodeNumber) : ''
|
||||
if (!match || (newKey !== key && state.episodeScripts[newKey])) {
|
||||
stillMissing.push(episodeNumber)
|
||||
continue
|
||||
}
|
||||
delete state.episodeScripts[key]
|
||||
state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber }
|
||||
updated += 1
|
||||
}
|
||||
|
||||
if (updated > 0) queueEpisodeScriptsWrite()
|
||||
|
||||
res.json({ ok: true, updated, stillMissing })
|
||||
})
|
||||
|
||||
// Admin: delete a script
|
||||
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!state.episodeScripts[episodeNumber]) {
|
||||
app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
|
||||
const key = req.params.key?.trim()
|
||||
if (!key || !state.episodeScripts[key]) {
|
||||
res.status(404).json({ message: 'Script not found.' }); return
|
||||
}
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
delete state.episodeScripts[key]
|
||||
queueEpisodeScriptsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
||||
let episodesCache = null
|
||||
let episodesCacheAt = 0
|
||||
let rawXmlCache = null
|
||||
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
||||
const EPISODES_CACHE_TTL = 10 * 60 * 1000
|
||||
|
||||
export async function fetchAllEpisodes() {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -6,6 +6,26 @@ import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||||
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
||||
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
|
||||
|
||||
const CP1252_EXTRA = {
|
||||
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
|
||||
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
|
||||
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
|
||||
}
|
||||
|
||||
// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that.
|
||||
function repairMojibake(text) {
|
||||
if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
|
||||
const bytes = []
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0)
|
||||
if (code <= 0xff) bytes.push(code)
|
||||
else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch])
|
||||
else return text
|
||||
}
|
||||
const fixed = Buffer.from(bytes).toString('utf8')
|
||||
return fixed.includes('\ufffd') ? text : fixed
|
||||
}
|
||||
|
||||
function decodeHtmlBody(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null
|
||||
const s = raw.trim()
|
||||
@@ -96,7 +116,7 @@ export function register(app) {
|
||||
}
|
||||
}
|
||||
|
||||
const subjectStr = typeof subject === 'string' ? subject.trim() : ''
|
||||
const subjectStr = typeof subject === 'string' ? repairMojibake(subject.trim()) : ''
|
||||
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
|
||||
|
||||
const submission = {
|
||||
@@ -105,7 +125,7 @@ export function register(app) {
|
||||
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
|
||||
name: fromName || fromEmail,
|
||||
email: fromEmail,
|
||||
message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'),
|
||||
message: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? body : '')].filter(Boolean).join('\n\n'),
|
||||
htmlBody: decodeHtmlBody(htmlBody),
|
||||
messageType: 'general',
|
||||
subscribe: false,
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Resend } from 'resend'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueNewsletterIssuesWrite } from '../data.js'
|
||||
import { escapeHtml } from '../helpers.js'
|
||||
import { resendRetry } from '../email.js'
|
||||
|
||||
const BASE = 'https://versebyversewithnate.us'
|
||||
const NEW_EPISODE_TOPIC_NAME = 'new episode'
|
||||
|
||||
function truncate(str, max) {
|
||||
const clean = String(str || '').trim()
|
||||
if (clean.length <= max) return clean
|
||||
return `${clean.slice(0, max - 1).trimEnd()}…`
|
||||
}
|
||||
|
||||
function stripSubjectPrefix(subject) {
|
||||
return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim()
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return String(str || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80)
|
||||
}
|
||||
|
||||
function uniqueSlug(base) {
|
||||
const root = slugify(base) || 'issue'
|
||||
let candidate = root
|
||||
let n = 2
|
||||
while (state.newsletterIssues.some(i => i.slug === candidate)) {
|
||||
candidate = `${root}-${n}`
|
||||
n += 1
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function extractDescription(text, previewText) {
|
||||
if (previewText && previewText.trim()) return truncate(previewText, 200)
|
||||
const paragraphs = String(text || '')
|
||||
.split(/\n{2,}/)
|
||||
.map(p => p.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase())
|
||||
return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200)
|
||||
}
|
||||
|
||||
// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email.
|
||||
function stripUnsubscribeLink(html) {
|
||||
return String(html || '').replace(
|
||||
/<p[^>]*>\s*<a[^>]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i,
|
||||
'',
|
||||
)
|
||||
}
|
||||
|
||||
function buildIssuePageHtml(issue) {
|
||||
const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}`
|
||||
const ogImage = `${BASE}/images/banner.png`
|
||||
const title = escapeHtml(issue.title)
|
||||
const description = escapeHtml(issue.description)
|
||||
|
||||
const metaBlock = `
|
||||
<title>${title} — Verse by Verse with Nate</title>
|
||||
<meta name="description" content="${description}"/>
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||||
<meta property="og:url" content="${escapeHtml(canonical)}"/>
|
||||
<meta property="og:title" content="${title}"/>
|
||||
<meta property="og:description" content="${description}"/>
|
||||
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||||
<meta property="og:image:alt" content="${title}"/>
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
<meta name="twitter:title" content="${title}"/>
|
||||
<meta name="twitter:description" content="${description}"/>
|
||||
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||||
<link rel="canonical" href="${escapeHtml(canonical)}"/>
|
||||
<style>
|
||||
/* The source HTML is a Resend/React Email template built for a narrow email-client
|
||||
viewport: its outer wrapper table uses align="left" (which browsers translate to
|
||||
float: left) and relies on the surrounding white to be invisible in a ~600px inbox
|
||||
pane. In a full-width browser tab that leaves a left-stuck column on a white field.
|
||||
Force it to sit centered on the site's dark background instead. */
|
||||
html, body { background-color: #0a0a08 !important; margin: 0; padding: 0; }
|
||||
table[align="left"] { float: none !important; margin-left: auto !important; margin-right: auto !important; }
|
||||
</style>
|
||||
</head>`
|
||||
|
||||
const topBar = `
|
||||
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
|
||||
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
|
||||
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">← All issues</a>
|
||||
</div></div>`
|
||||
|
||||
const ctaBlock = `
|
||||
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
|
||||
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
||||
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
|
||||
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
|
||||
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
|
||||
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
|
||||
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">← Back to the newsletter archive</a></p>
|
||||
</td></tr></tbody></table>
|
||||
</td></tr></tbody></table>
|
||||
</body>`
|
||||
|
||||
let html = stripUnsubscribeLink(issue.html)
|
||||
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
||||
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
||||
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
|
||||
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
||||
return html
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// ── Admin: browse Resend broadcasts, flagged by New Episode topic ────────
|
||||
|
||||
app.get('/api/admin/newsletter/resend-broadcasts', requireAdminAuth, async (_req, res) => {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
|
||||
resendRetry(() => resend.topics.list()),
|
||||
resendRetry(() => resend.broadcasts.list()),
|
||||
])
|
||||
if (topicsError) throw new Error(topicsError.message)
|
||||
if (listError) throw new Error(listError.message)
|
||||
|
||||
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
|
||||
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
|
||||
|
||||
// Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit.
|
||||
const details = []
|
||||
for (let i = 0; i < sentBroadcasts.length; i += 4) {
|
||||
const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id))))
|
||||
details.push(...chunk)
|
||||
}
|
||||
|
||||
const broadcasts = details.map(({ data }, i) => {
|
||||
const summary = sentBroadcasts[i]
|
||||
if (!data) return null
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subject: data.subject,
|
||||
sentAt: data.sent_at ?? summary.sent_at,
|
||||
topicId: data.topic_id ?? null,
|
||||
matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id,
|
||||
alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id),
|
||||
}
|
||||
}).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime())
|
||||
|
||||
res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic })
|
||||
} catch (err) {
|
||||
console.error('[newsletter] failed to list Resend broadcasts:', err)
|
||||
res.status(502).json({ message: 'Could not reach Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Admin: manage published issues ────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => {
|
||||
const issues = [...state.newsletterIssues]
|
||||
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
|
||||
.map(({ html, ...rest }) => rest)
|
||||
res.json({ issues })
|
||||
})
|
||||
|
||||
app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => {
|
||||
const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {}
|
||||
if (!broadcastId || typeof broadcastId !== 'string') {
|
||||
res.status(400).json({ message: 'broadcastId is required.' }); return
|
||||
}
|
||||
if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) {
|
||||
res.status(409).json({ message: 'This broadcast has already been published.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const { data, error } = await resend.broadcasts.get(broadcastId)
|
||||
if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.')
|
||||
if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return }
|
||||
|
||||
const title = typeof titleOverride === 'string' && titleOverride.trim()
|
||||
? titleOverride.trim().slice(0, 200)
|
||||
: stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue'
|
||||
const slug = typeof slugOverride === 'string' && slugOverride.trim()
|
||||
? uniqueSlug(slugOverride)
|
||||
: uniqueSlug(title)
|
||||
|
||||
const issue = {
|
||||
id: randomUUID(),
|
||||
resendBroadcastId: data.id,
|
||||
slug,
|
||||
title,
|
||||
subject: data.subject ?? '',
|
||||
description: extractDescription(data.text, data.preview_text),
|
||||
html: data.html,
|
||||
sentAt: data.sent_at ?? null,
|
||||
publishedAt: new Date().toISOString(),
|
||||
public: isPublic !== false,
|
||||
}
|
||||
|
||||
state.newsletterIssues.push(issue)
|
||||
queueNewsletterIssuesWrite()
|
||||
const { html, ...rest } = issue
|
||||
res.json({ ok: true, issue: rest })
|
||||
} catch (err) {
|
||||
console.error('[newsletter] failed to publish issue:', err)
|
||||
res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
|
||||
const issue = state.newsletterIssues.find(i => i.id === req.params.id)
|
||||
if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return }
|
||||
|
||||
if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200)
|
||||
if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) {
|
||||
const candidate = slugify(req.body.slug)
|
||||
if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return }
|
||||
if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) {
|
||||
res.status(409).json({ message: 'That slug is already in use.' }); return
|
||||
}
|
||||
issue.slug = candidate
|
||||
}
|
||||
if (typeof req.body?.public === 'boolean') issue.public = req.body.public
|
||||
|
||||
queueNewsletterIssuesWrite()
|
||||
const { html, ...rest } = issue
|
||||
res.json({ ok: true, issue: rest })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
|
||||
const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id)
|
||||
if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return }
|
||||
state.newsletterIssues.splice(idx, 1)
|
||||
queueNewsletterIssuesWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Public ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/newsletter/issues', (_req, res) => {
|
||||
const issues = state.newsletterIssues
|
||||
.filter(i => i.public !== false)
|
||||
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
|
||||
.map(({ html, ...rest }) => rest)
|
||||
res.json({ issues })
|
||||
})
|
||||
|
||||
app.get('/newsletter/:slug', (req, res, next) => {
|
||||
const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false)
|
||||
if (!issue) { next(); return }
|
||||
res.type('html').send(buildIssuePageHtml(issue))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile, unlink, mkdir } from 'node:fs/promises'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import {
|
||||
sendNuggetsUploadNotification,
|
||||
sendNuggetsHostWelcomeEmail,
|
||||
sendNuggetsHostOtpEmail,
|
||||
getCanonicalBaseUrl,
|
||||
} from '../email.js'
|
||||
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||
import {
|
||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||
NUGGETS_UPLOAD_HISTORY_FILE,
|
||||
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
|
||||
NUGGETS_PORTAL_GUIDELINES_FILE,
|
||||
NUGGETS_NOTIFY_EMAIL,
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_INFO_DIR,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
SEAFILE_NUGGETS_SHARE_URL,
|
||||
HOST_SESSION_COOKIE,
|
||||
HOST_SESSION_TTL_MS,
|
||||
DATA_DIR,
|
||||
} from '../config.js'
|
||||
|
||||
// ── In-memory host sessions ───────────────────────────────────────────────
|
||||
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
|
||||
|
||||
function createHostSession(hostId, hostName) {
|
||||
const token = randomUUID()
|
||||
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
function getHostSession(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (!token) return null
|
||||
const session = hostSessions.get(token)
|
||||
if (!session || session.expiresAt <= Date.now()) {
|
||||
hostSessions.delete(token)
|
||||
return null
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
function setHostSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function clearHostSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function requireHostAuth(req, res, next) {
|
||||
if (!getHostSession(req)) {
|
||||
res.status(401).json({ message: 'Not authenticated.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── In-memory pending OTP store ───────────────────────────────────────────
|
||||
const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts }
|
||||
|
||||
function generateOtpCode() {
|
||||
// 6-digit numeric code
|
||||
return String(Math.floor(100000 + Math.random() * 900000))
|
||||
}
|
||||
|
||||
// ── Host credentials data ─────────────────────────────────────────────────
|
||||
function hashPassword(pw) {
|
||||
return createHash('sha256').update(String(pw)).digest('hex')
|
||||
}
|
||||
|
||||
function passwordsMatch(plain, hash) {
|
||||
const a = Buffer.from(hashPassword(plain), 'utf8')
|
||||
const b = Buffer.from(hash, 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
async function loadUploadHosts() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { hosts: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUploadHosts(data) {
|
||||
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── History helpers ───────────────────────────────────────────────────────
|
||||
export async function loadHistory() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { entries: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async function appendHistory(entry) {
|
||||
const data = await loadHistory()
|
||||
data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() })
|
||||
// Keep last 1000 entries
|
||||
if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000)
|
||||
await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
// ── Announcement helpers ──────────────────────────────────────────────────
|
||||
async function loadAnnouncement() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { text: '', updatedAt: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnnouncement(text) {
|
||||
const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() }
|
||||
await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Guidelines helpers ────────────────────────────────────────────────────
|
||||
const DEFAULT_GUIDELINES = [
|
||||
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
|
||||
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
|
||||
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
|
||||
'Normalize to -1 dBFS before exporting.',
|
||||
'Match the episode name here to the file name exactly so search stays clean.',
|
||||
]
|
||||
|
||||
async function loadGuidelines() {
|
||||
try {
|
||||
const raw = await readFile(NUGGETS_PORTAL_GUIDELINES_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return { lines: DEFAULT_GUIDELINES, updatedAt: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGuidelines(lines) {
|
||||
const data = { lines, updatedAt: new Date().toISOString() }
|
||||
await writeFile(NUGGETS_PORTAL_GUIDELINES_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_TEMP_DIR,
|
||||
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
|
||||
})
|
||||
|
||||
// ── Seafile helpers ───────────────────────────────────────────────────────
|
||||
function seafileConfigured() {
|
||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||
}
|
||||
|
||||
async function getSeafileUploadLink(dir) {
|
||||
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?p=${encodeURIComponent(dir)}`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
|
||||
}
|
||||
// Seafile returns a quoted string: "https://..."
|
||||
const text = await res.text()
|
||||
return text.replace(/^"|"$/g, '')
|
||||
}
|
||||
|
||||
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', new Blob([fileBuffer]), filename)
|
||||
formData.append('parent_dir', parentDir)
|
||||
formData.append('filename', filename)
|
||||
formData.append('replace', '1')
|
||||
|
||||
const res = await fetch(uploadLink, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
|
||||
}
|
||||
await res.text()
|
||||
}
|
||||
|
||||
// ── Rate limiters ─────────────────────────────────────────────────────────
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
const otpRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' },
|
||||
})
|
||||
|
||||
// ── Route registration ────────────────────────────────────────────────────
|
||||
export function register(app) {
|
||||
// Ensure temp upload dir exists (non-blocking; created well before any upload)
|
||||
mkdir(UPLOAD_TEMP_DIR, { recursive: true }).catch(err => {
|
||||
console.error('[nuggets-upload] could not create upload-tmp dir:', err.message)
|
||||
})
|
||||
// ── Host auth ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/me', async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
if (!session) {
|
||||
res.json({ authenticated: false })
|
||||
return
|
||||
}
|
||||
const data = await loadUploadHosts()
|
||||
const host = data.hosts.find(h => h.id === session.hostId)
|
||||
res.json({
|
||||
authenticated: true,
|
||||
hostName: session.hostName,
|
||||
configured: seafileConfigured(),
|
||||
twoFaEnabled: Boolean(host?.twoFa),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ message: 'Email and password are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
const host = data.hosts.find(h => h.email === email && h.active)
|
||||
if (!host || !passwordsMatch(password, host.passwordHash)) {
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled for this host
|
||||
if (host.twoFa) {
|
||||
const code = generateOtpCode()
|
||||
const otpToken = randomUUID()
|
||||
pendingOtpStore.set(otpToken, {
|
||||
hostId: host.id,
|
||||
hostName: host.name,
|
||||
code,
|
||||
expiresAt: Date.now() + OTP_TTL_MS,
|
||||
attempts: 0,
|
||||
})
|
||||
sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch(
|
||||
err => console.error('[nuggets-otp] send error:', err),
|
||||
)
|
||||
res.json({ requiresOtp: true, otpToken })
|
||||
return
|
||||
}
|
||||
|
||||
const token = createHostSession(host.id, host.name)
|
||||
setHostSessionCookie(res, token)
|
||||
res.json({ ok: true, hostName: host.name })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => {
|
||||
const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : ''
|
||||
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''
|
||||
|
||||
if (!otpToken || !code) {
|
||||
res.status(400).json({ message: 'OTP token and code are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const pending = pendingOtpStore.get(otpToken)
|
||||
if (!pending || pending.expiresAt <= Date.now()) {
|
||||
pendingOtpStore.delete(otpToken)
|
||||
res.status(401).json({ message: 'Code expired. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
pending.attempts += 1
|
||||
if (pending.attempts > 5) {
|
||||
pendingOtpStore.delete(otpToken)
|
||||
res.status(429).json({ message: 'Too many attempts. Please sign in again.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.code !== code) {
|
||||
res.status(401).json({ message: 'Incorrect code. Try again.' })
|
||||
return
|
||||
}
|
||||
|
||||
pendingOtpStore.delete(otpToken)
|
||||
const sessionToken = createHostSession(pending.hostId, pending.hostName)
|
||||
setHostSessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, hostName: pending.hostName })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[HOST_SESSION_COOKIE]
|
||||
if (token) hostSessions.delete(token)
|
||||
clearHostSessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
res.status(400).json({ message: 'Current and new password are required.' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
res.status(400).json({ message: 'New password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
|
||||
data.hosts[idx].passwordHash = hashPassword(newPassword)
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts[idx].twoFa = true
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === session.hostId)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host account not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts[idx].twoFa = false
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Episode upload (audio file + metadata) ───────────────────────────
|
||||
|
||||
app.post('/api/nuggets-upload/upload', requireHostAuth, (req, res, next) => {
|
||||
upload.single('file')(req, res, err => {
|
||||
if (!err) return next()
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ message: 'File exceeds the 500 MB limit.' })
|
||||
}
|
||||
console.error('[nuggets-upload] multer error:', err.message)
|
||||
return res.status(500).json({ message: `File upload error: ${err.message}` })
|
||||
})
|
||||
}, async (req, res) => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ message: 'No file received.' })
|
||||
return
|
||||
}
|
||||
|
||||
const session = getHostSession(req)
|
||||
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
|
||||
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
|
||||
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
|
||||
const originalName = req.file.originalname || req.file.filename
|
||||
const tempPath = req.file.path
|
||||
|
||||
if (!episodeName || !passage) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
res.status(400).json({ message: 'Episode name and passage are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!seafileConfigured()) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fileBuffer = await readFile(tempPath)
|
||||
|
||||
// Upload audio to /Episode Upload/
|
||||
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
|
||||
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
|
||||
await unlink(tempPath).catch(() => {})
|
||||
|
||||
// Build and upload info file to /Episode Info/
|
||||
const now = new Date()
|
||||
const dateStr = now.toISOString().slice(0, 10)
|
||||
const infoContent = [
|
||||
`Episode: ${episodeName}`,
|
||||
`Host: ${session?.hostName ?? 'Unknown'}`,
|
||||
`Passage: ${passage}`,
|
||||
`Audio File: ${originalName}`,
|
||||
`Submitted: ${now.toUTCString()}`,
|
||||
'',
|
||||
'Description:',
|
||||
description || '(none)',
|
||||
].join('\n')
|
||||
|
||||
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
|
||||
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
|
||||
const infoBuffer = Buffer.from(infoContent, 'utf8')
|
||||
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
|
||||
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
|
||||
|
||||
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
|
||||
|
||||
// Mark audio file as seen so the Seafile watcher doesn't double-notify
|
||||
await markSeafileFileSeen(originalName)
|
||||
|
||||
// Persist to history
|
||||
await appendHistory({
|
||||
hostId: session?.hostId ?? '',
|
||||
hostName: session?.hostName ?? '',
|
||||
episodeName,
|
||||
passage,
|
||||
description,
|
||||
filename: originalName,
|
||||
infoFile: infoFilename,
|
||||
})
|
||||
|
||||
// Send admin notification (fire-and-forget)
|
||||
sendNuggetsUploadNotification({
|
||||
to: NUGGETS_NOTIFY_EMAIL,
|
||||
source: 'portal',
|
||||
hostName: session?.hostName ?? '',
|
||||
episodeName,
|
||||
passage,
|
||||
description,
|
||||
filename: originalName,
|
||||
infoFile: infoFilename,
|
||||
}).catch(err => console.error('[nuggets-upload] notify error:', err))
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
episodeName,
|
||||
filename: originalName,
|
||||
infoFile: infoFilename,
|
||||
seafileUrl: SEAFILE_NUGGETS_SHARE_URL,
|
||||
})
|
||||
} catch (err) {
|
||||
await unlink(tempPath).catch(() => {})
|
||||
console.error('[nuggets-upload] upload error:', err.message)
|
||||
res.status(500).json({ message: `Upload failed: ${err.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Host: episode history ─────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => {
|
||||
const session = getHostSession(req)
|
||||
const data = await loadHistory()
|
||||
const entries = data.entries.filter(e => e.hostId === session.hostId)
|
||||
res.json({ entries })
|
||||
})
|
||||
|
||||
// ── Public: announcement ──────────────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/announcement', async (_req, res) => {
|
||||
const data = await loadAnnouncement()
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── Public: recording guidelines ─────────────────────────────────────
|
||||
|
||||
app.get('/api/nuggets-upload/guidelines', async (_req, res) => {
|
||||
const data = await loadGuidelines()
|
||||
res.json(data)
|
||||
})
|
||||
|
||||
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||
const data = await loadUploadHosts()
|
||||
// Strip password hashes from the response
|
||||
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
|
||||
res.json({ hosts })
|
||||
})
|
||||
|
||||
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
|
||||
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
|
||||
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!name || !email || !password) {
|
||||
res.status(400).json({ message: 'Name, email, and password are required.' })
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const data = await loadUploadHosts()
|
||||
if (data.hosts.some(h => h.email === email)) {
|
||||
res.status(409).json({ message: 'An account with that email already exists.' })
|
||||
return
|
||||
}
|
||||
|
||||
const host = {
|
||||
id: randomUUID(),
|
||||
name,
|
||||
email,
|
||||
passwordHash: hashPassword(password),
|
||||
active: true,
|
||||
twoFa: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
data.hosts.push(host)
|
||||
await saveUploadHosts(data)
|
||||
|
||||
sendNuggetsHostWelcomeEmail({
|
||||
to: email,
|
||||
name,
|
||||
username: email,
|
||||
password,
|
||||
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
|
||||
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = host
|
||||
res.json({ ok: true, host: safeHost, emailSent: true })
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
|
||||
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
|
||||
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
|
||||
if (req.body.password.length < 8) {
|
||||
res.status(400).json({ message: 'Password must be at least 8 characters.' })
|
||||
return
|
||||
}
|
||||
patch.passwordHash = hashPassword(req.body.password)
|
||||
}
|
||||
|
||||
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
|
||||
data.hosts[idx] = { ...data.hosts[idx], ...patch }
|
||||
await saveUploadHosts(data)
|
||||
|
||||
// Email the host their new password if they have an address on file
|
||||
if (newPassword && data.hosts[idx].email) {
|
||||
sendNuggetsHostWelcomeEmail({
|
||||
to: data.hosts[idx].email,
|
||||
name: data.hosts[idx].name,
|
||||
username: data.hosts[idx].email,
|
||||
password: newPassword,
|
||||
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
|
||||
}).catch(err => console.error('[nuggets-host] reset email error:', err))
|
||||
}
|
||||
|
||||
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
|
||||
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const data = await loadUploadHosts()
|
||||
const idx = data.hosts.findIndex(h => h.id === id)
|
||||
if (idx === -1) {
|
||||
res.status(404).json({ message: 'Host not found.' })
|
||||
return
|
||||
}
|
||||
data.hosts.splice(idx, 1)
|
||||
await saveUploadHosts(data)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Admin: announcement ───────────────────────────────────────────────
|
||||
|
||||
app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => {
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text : ''
|
||||
if (text.length > 500) {
|
||||
res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' })
|
||||
return
|
||||
}
|
||||
const data = await saveAnnouncement(text)
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => {
|
||||
const data = await saveAnnouncement('')
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
// ── Admin: recording guidelines ───────────────────────────────────────
|
||||
|
||||
app.post('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (req, res) => {
|
||||
const raw = typeof req.body?.lines === 'string' ? req.body.lines : ''
|
||||
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
|
||||
if (lines.length > 20) {
|
||||
res.status(400).json({ message: 'Maximum 20 guideline lines.' })
|
||||
return
|
||||
}
|
||||
const data = await saveGuidelines(lines)
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (_req, res) => {
|
||||
const data = await saveGuidelines(DEFAULT_GUIDELINES)
|
||||
res.json({ ok: true, ...data })
|
||||
})
|
||||
|
||||
// ── Admin: upload history ─────────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
|
||||
const data = await loadHistory()
|
||||
res.json({ entries: data.entries })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { Readable } from 'node:stream'
|
||||
import path from 'node:path'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { loadHistory } from './nuggets-host-upload.js'
|
||||
import {
|
||||
DATA_DIR,
|
||||
SEAFILE_URL,
|
||||
SEAFILE_TOKEN,
|
||||
SEAFILE_NUGGETS_REPO_ID,
|
||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||
} from '../config.js'
|
||||
|
||||
const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json')
|
||||
|
||||
const AUDIO_TYPES = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
}
|
||||
|
||||
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
||||
|
||||
export async function loadWorkflow() {
|
||||
try {
|
||||
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||
} catch {
|
||||
return { items: {} }
|
||||
}
|
||||
}
|
||||
|
||||
export function entryId(entry) {
|
||||
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
function emptyState() {
|
||||
return {
|
||||
steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time
|
||||
coldOpenText: '',
|
||||
notes: '',
|
||||
spotifyUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
function seafileConfigured() {
|
||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||
}
|
||||
|
||||
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
|
||||
export async function countPendingUploads() {
|
||||
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||
return history.entries.filter(entry => {
|
||||
const steps = wf.items[entryId(entry)]?.steps ?? {}
|
||||
return !WORKFLOW_STEPS.every(step => steps[step])
|
||||
}).length
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
||||
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||
const items = history.entries.map(entry => {
|
||||
const id = entryId(entry)
|
||||
return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } }
|
||||
})
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => {
|
||||
const { id } = req.params
|
||||
const history = await loadHistory()
|
||||
if (!history.entries.some(e => entryId(e) === id)) {
|
||||
res.status(404).json({ message: 'Upload not found.' })
|
||||
return
|
||||
}
|
||||
const wf = await loadWorkflow()
|
||||
const prev = { ...emptyState(), ...(wf.items[id] ?? {}) }
|
||||
const body = req.body ?? {}
|
||||
const next = { ...prev, steps: { ...prev.steps } }
|
||||
|
||||
if (body.steps && typeof body.steps === 'object') {
|
||||
for (const s of WORKFLOW_STEPS) {
|
||||
if (typeof body.steps[s] === 'boolean') {
|
||||
// Keep the original completion time if the step was already done
|
||||
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
|
||||
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
|
||||
if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500)
|
||||
|
||||
wf.items[id] = next
|
||||
await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8')
|
||||
res.json({ ok: true, state: next })
|
||||
})
|
||||
|
||||
// Stream the uploaded audio from Seafile so it can be auditioned in the admin.
|
||||
app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => {
|
||||
if (!seafileConfigured()) {
|
||||
res.status(503).json({ message: 'Seafile is not configured.' })
|
||||
return
|
||||
}
|
||||
const history = await loadHistory()
|
||||
const entry = history.entries.find(e => entryId(e) === req.params.id)
|
||||
if (!entry) {
|
||||
res.status(404).json({ message: 'Upload not found.' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}`
|
||||
const linkRes = await fetch(
|
||||
`${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
||||
{ headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
if (!linkRes.ok) {
|
||||
console.error(`[nuggets-workflow] Seafile link lookup for "${filePath}" failed: ${linkRes.status}`)
|
||||
res.status(404).json({ message: 'Audio file not found in Seafile.' })
|
||||
return
|
||||
}
|
||||
const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '')
|
||||
|
||||
const headers = {}
|
||||
if (req.headers.range) headers.Range = req.headers.range
|
||||
const upstream = await fetch(downloadUrl, { headers })
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
console.error(`[nuggets-workflow] Seafile download failed: ${upstream.status}`)
|
||||
res.status(502).json({ message: 'Could not fetch audio from Seafile.' })
|
||||
return
|
||||
}
|
||||
res.status(upstream.status)
|
||||
for (const h of ['content-length', 'content-range', 'accept-ranges']) {
|
||||
const v = upstream.headers.get(h)
|
||||
if (v) res.setHeader(h, v)
|
||||
}
|
||||
// Seafile serves downloads as application/octet-stream, which browsers refuse to play
|
||||
// (the site sends nosniff), so set the audio type from the file extension.
|
||||
const ext = path.extname(entry.filename).toLowerCase()
|
||||
res.setHeader('content-type', AUDIO_TYPES[ext] ?? 'audio/mpeg')
|
||||
if (!res.getHeader('accept-ranges')) res.setHeader('accept-ranges', 'bytes')
|
||||
const stream = Readable.fromWeb(upstream.body)
|
||||
req.on('close', () => stream.destroy())
|
||||
stream.on('error', () => res.end())
|
||||
stream.pipe(res)
|
||||
} catch (err) {
|
||||
console.error('[nuggets-workflow] audio error:', err.message, err.cause?.message ?? '')
|
||||
// Admin-only route, so the reason is safe to show and saves a trip to the server logs.
|
||||
const reason = [err.message, err.cause?.message].filter(Boolean).join(': ')
|
||||
if (!res.headersSent) res.status(500).json({ message: `Audio stream failed${reason ? ` (${reason})` : ''}.` })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { parseRssItems } from '../rss-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { queueNuggetsTagsWrite } from '../data.js'
|
||||
|
||||
let cache = null // { url, episodes }
|
||||
let cacheAt = 0
|
||||
@@ -9,7 +11,7 @@ function feedUrl() {
|
||||
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
|
||||
}
|
||||
|
||||
async function fetchNuggetsEpisodes() {
|
||||
export async function fetchNuggetsEpisodes() {
|
||||
const url = feedUrl()
|
||||
if (!url) { cache = null; return [] }
|
||||
|
||||
@@ -37,12 +39,47 @@ async function fetchNuggetsEpisodes() {
|
||||
}
|
||||
}
|
||||
|
||||
function withTags(episodes) {
|
||||
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
|
||||
}
|
||||
|
||||
function cleanTags(input) {
|
||||
const list = Array.isArray(input) ? input : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const t of list) {
|
||||
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
||||
const key = tag.toLowerCase()
|
||||
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
|
||||
}
|
||||
return out.slice(0, 12)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
|
||||
} catch (err) {
|
||||
res.status(502).json({ message: 'Could not load episodes from the feed.' })
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
|
||||
const audioUrl = String(req.body?.audioUrl ?? '')
|
||||
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
|
||||
const tags = cleanTags(req.body?.tags)
|
||||
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
|
||||
else delete state.nuggetsEpisodeTags[audioUrl]
|
||||
queueNuggetsTagsWrite()
|
||||
res.json({ ok: true, tags })
|
||||
})
|
||||
|
||||
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes: episodes.slice(0, 6), configured })
|
||||
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
@@ -53,7 +90,7 @@ export function register(app) {
|
||||
const configured = Boolean(feedUrl())
|
||||
try {
|
||||
const episodes = await fetchNuggetsEpisodes()
|
||||
res.json({ episodes, configured })
|
||||
res.json({ episodes: withTags(episodes), configured })
|
||||
} catch (err) {
|
||||
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||
res.json({ episodes: [], configured })
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { scriptKey } from '../episode-script-keys.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
|
||||
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
||||
|
||||
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
||||
|
||||
export async function loadPodcastWorkflow() {
|
||||
try {
|
||||
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
||||
} catch {
|
||||
return { planned: [], items: {}, laneOverrides: {}, laneSeasons: {} }
|
||||
}
|
||||
}
|
||||
|
||||
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
|
||||
function emptyState() {
|
||||
return {
|
||||
steps: Object.fromEntries(STEPS.map(s => [s, null])),
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
||||
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
|
||||
const saved = wf.laneSeasons?.[lane]
|
||||
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
||||
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
||||
return fallback ? String(fallback) : ''
|
||||
}
|
||||
|
||||
function laneFor(season, wf) {
|
||||
const s = String(season ?? '')
|
||||
if (s && s === laneSeasonOf('A', wf)) return 'A'
|
||||
if (s && s === laneSeasonOf('B', wf)) return 'B'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
const plannedKey = p => scriptKey(p.season, p.episodeNumber)
|
||||
|
||||
function toIsoDate(pubDate) {
|
||||
const d = new Date(pubDate)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
||||
const wf = await loadPodcastWorkflow()
|
||||
let feed = []
|
||||
let feedOk = true
|
||||
try {
|
||||
feed = await fetchAllEpisodes()
|
||||
} catch {
|
||||
feedOk = false
|
||||
}
|
||||
|
||||
const byKey = new Map()
|
||||
const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch })
|
||||
|
||||
for (const p of wf.planned) {
|
||||
const key = plannedKey(p)
|
||||
const prev = byKey.get(key)
|
||||
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true })
|
||||
}
|
||||
for (const item of feed) {
|
||||
const hasNum = /^\d+$/.test(item.episode)
|
||||
let key
|
||||
if (item.season && hasNum) {
|
||||
key = scriptKey(item.season, item.episode)
|
||||
} else {
|
||||
// No season in the feed: pair with a script/planned episode of the same title, else keep it on its own.
|
||||
const t = normTitle(item.title)
|
||||
const match = [...byKey.values()].find(e => !e.released && normTitle(e.title) === t)
|
||||
key = match ? match.key : `t:${t}`
|
||||
}
|
||||
const prev = byKey.get(key)
|
||||
upsert(key, {
|
||||
season: item.season || prev?.season || '',
|
||||
episodeNumber: hasNum ? item.episode : (prev?.episodeNumber ?? ''),
|
||||
title: item.title || prev?.title || '',
|
||||
released: true,
|
||||
releaseDate: toIsoDate(item.pubDate),
|
||||
spotifyUrl: item.link || '',
|
||||
})
|
||||
}
|
||||
|
||||
const items = [...byKey.values()].map(e => ({
|
||||
...e,
|
||||
lane: wf.laneOverrides[e.key] ?? laneFor(e.season, wf),
|
||||
laneOverride: wf.laneOverrides[e.key] ?? '',
|
||||
released: Boolean(e.released),
|
||||
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
|
||||
}))
|
||||
items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0))
|
||||
res.json({ items, feedOk, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
|
||||
})
|
||||
|
||||
// Start the workflow for a new episode. Name, season and episode number are all required
|
||||
// so the episode lands in the right lane and matches the feed once it is released.
|
||||
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
|
||||
const season = String(req.body?.season ?? '').trim()
|
||||
const episodeNumber = String(req.body?.episodeNumber ?? '').trim()
|
||||
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : ''
|
||||
if (!title) {
|
||||
res.status(400).json({ message: 'Episode name is required.' })
|
||||
return
|
||||
}
|
||||
if (!/^\d+$/.test(season) || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
||||
return
|
||||
}
|
||||
const wf = await loadPodcastWorkflow()
|
||||
const key = scriptKey(season, episodeNumber)
|
||||
if (wf.planned.some(p => plannedKey(p) === key)) {
|
||||
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
||||
return
|
||||
}
|
||||
wf.planned.push({ season, episodeNumber, title })
|
||||
await save(wf)
|
||||
res.json({ ok: true, key })
|
||||
})
|
||||
|
||||
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
||||
const wf = await loadPodcastWorkflow()
|
||||
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
||||
delete wf.items[req.params.key]
|
||||
delete wf.laneOverrides[req.params.key]
|
||||
await save(wf)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
||||
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
||||
const wf = await loadPodcastWorkflow()
|
||||
for (const lane of ['A', 'B']) {
|
||||
const v = req.body?.[lane]
|
||||
if (v === undefined) continue
|
||||
const str = String(v).trim()
|
||||
if (str === '') delete wf.laneSeasons[lane]
|
||||
else if (/^\d+$/.test(str)) wf.laneSeasons[lane] = str
|
||||
else { res.status(400).json({ message: 'Season must be a whole number.' }); return }
|
||||
}
|
||||
await save(wf)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Assign an episode to a lane by hand ('auto' clears the override and goes back to season matching).
|
||||
app.put('/api/admin/podcast-workflow/:key/lane', requireAdminAuth, async (req, res) => {
|
||||
const lane = req.body?.lane
|
||||
if (!['A', 'B', 'other', 'auto'].includes(lane)) {
|
||||
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
||||
return
|
||||
}
|
||||
const wf = await loadPodcastWorkflow()
|
||||
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
||||
else wf.laneOverrides[req.params.key] = lane
|
||||
await save(wf)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
||||
const { key } = req.params
|
||||
const wf = await loadPodcastWorkflow()
|
||||
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
||||
const next = { ...prev, steps: { ...prev.steps } }
|
||||
const body = req.body ?? {}
|
||||
if (body.steps && typeof body.steps === 'object') {
|
||||
for (const s of STEPS) {
|
||||
if (typeof body.steps[s] === 'boolean') {
|
||||
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
|
||||
wf.items[key] = next
|
||||
await save(wf)
|
||||
res.json({ ok: true, state: next })
|
||||
})
|
||||
}
|
||||
+81
-9
@@ -1,4 +1,5 @@
|
||||
import express from 'express'
|
||||
import { applyFeedRelease } from '../feed-release.js'
|
||||
import path from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
||||
@@ -11,11 +12,38 @@ import {
|
||||
DEFAULT_SEO,
|
||||
APP_VERSION,
|
||||
GIT_COMMIT,
|
||||
DATA_FILE,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { loadSiteContentFile } from '../data.js'
|
||||
import { DATA_FILE } from '../config.js'
|
||||
import { sanitizeRedirectRules } from '../study-helpers.js'
|
||||
import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
import { fetchNuggetsEpisodes } from './nuggets.js'
|
||||
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
||||
|
||||
// Build a regex that matches scripture references like "Titus 1:5-9" or "1 Cor 13:4-7"
|
||||
// out of plain text. Sorted longest-first so "1 Corinthians" beats "1 Cor".
|
||||
const _refPatterns = []
|
||||
for (const b of BIBLE_BOOKS) {
|
||||
_refPatterns.push(b.name, ...(b.aliases ?? []))
|
||||
}
|
||||
_refPatterns.sort((a, z) => z.length - a.length)
|
||||
const _bookPart = _refPatterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||
const SCRIPTURE_REF_RE = new RegExp(
|
||||
`(${_bookPart})\\s+(\\d+):(\\d+)(?:-((?:\\d+):)?(\\d+))?`,
|
||||
'gi',
|
||||
)
|
||||
|
||||
function extractScriptureRefs(text) {
|
||||
const refs = []
|
||||
const seen = new Set()
|
||||
for (const m of String(text || '').matchAll(SCRIPTURE_REF_RE)) {
|
||||
const ref = m[0].trim()
|
||||
const key = ref.toLowerCase()
|
||||
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
|
||||
@@ -32,13 +60,14 @@ export function register(app) {
|
||||
content = parsed.siteContent
|
||||
} catch { content = {} }
|
||||
}
|
||||
content = await applyFeedRelease(content)
|
||||
|
||||
const seo = content?.seo ?? DEFAULT_SEO
|
||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||
|
||||
res.type('text/plain').send(
|
||||
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
||||
['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -75,10 +104,20 @@ export function register(app) {
|
||||
}
|
||||
}
|
||||
// Dynamic episode highlight URLs
|
||||
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []
|
||||
const highlights = [
|
||||
...(Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []),
|
||||
...(Array.isArray(state.cachedSiteContent?.finishedBooks) ? state.cachedSiteContent.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||
]
|
||||
for (const ep of highlights) {
|
||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||
}
|
||||
// The standalone gospel page
|
||||
if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`)
|
||||
// Newsletter archive + published issues
|
||||
urls.push(`${root}/newsletter`)
|
||||
for (const issue of state.newsletterIssues) {
|
||||
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
|
||||
}
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -116,7 +155,8 @@ export function register(app) {
|
||||
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
||||
res.redirect(302, '/questions'); return
|
||||
}
|
||||
const sourceQuestions = state.questions
|
||||
// Same source as /api/questions, so a freshly published answer's share link works straight away
|
||||
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
||||
if (!question) {
|
||||
res.redirect(302, '/questions'); return
|
||||
@@ -160,10 +200,13 @@ export function register(app) {
|
||||
})
|
||||
|
||||
// Scripture index — every passage covered across released study lessons and podcast episodes
|
||||
app.get('/api/public/scripture-index', (_req, res) => {
|
||||
const content = state.cachedSiteContent
|
||||
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||
const content = await applyFeedRelease(state.cachedSiteContent)
|
||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const allEpisodes = [
|
||||
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
||||
...(Array.isArray(content?.finishedBooks) ? content.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||
]
|
||||
const cutoff = new Date()
|
||||
const entries = []
|
||||
|
||||
@@ -174,7 +217,8 @@ export function register(app) {
|
||||
if (!sec.id || !sec.reference || !sec.releasedAt) continue
|
||||
if (new Date(sec.releasedAt) > cutoff) continue
|
||||
entries.push({
|
||||
reference: `${bookName} ${sec.reference}`,
|
||||
// Section references often already include the book ("Titus 1:1-2"); only prepend when they don't
|
||||
reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`,
|
||||
sourceType: 'study',
|
||||
sourceTitle: s.title || s.slug,
|
||||
sectionTitle: sec.title || '',
|
||||
@@ -196,6 +240,32 @@ export function register(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scripture refs from main podcast episode descriptions
|
||||
try {
|
||||
const mainEps = await fetchAllEpisodes()
|
||||
for (const ep of mainEps) {
|
||||
if (!ep.description) continue
|
||||
const refs = extractScriptureRefs(ep.description)
|
||||
for (const ref of refs) {
|
||||
const title = ep.episode ? `Episode ${ep.episode}: ${ep.title}` : ep.title
|
||||
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: ep.link || '/episodes' })
|
||||
}
|
||||
}
|
||||
} catch { /* keep going if RSS is down */ }
|
||||
|
||||
// Extract scripture refs from Nuggets episode descriptions
|
||||
try {
|
||||
const nuggetsEps = await fetchNuggetsEpisodes()
|
||||
for (const ep of nuggetsEps) {
|
||||
if (!ep.description) continue
|
||||
const refs = extractScriptureRefs(ep.description)
|
||||
for (const ref of refs) {
|
||||
const title = ep.episode ? `Nuggets Ep. ${ep.episode}: ${ep.title}` : ep.title
|
||||
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: '/nuggets' })
|
||||
}
|
||||
}
|
||||
} catch { /* keep going if RSS is down */ }
|
||||
|
||||
res.json({ entries })
|
||||
})
|
||||
|
||||
@@ -205,6 +275,8 @@ export function register(app) {
|
||||
app.use(async (_req, res) => {
|
||||
try {
|
||||
const html = await readFile(INDEX_FILE, 'utf8')
|
||||
// Unknown URLs still render the app's "Page not found" screen, but with a real 404 status
|
||||
if (!isKnownSitePath(_req.path)) res.status(404)
|
||||
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
|
||||
} catch {
|
||||
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
||||
|
||||
+128
-31
@@ -4,8 +4,66 @@ import { MAX_QUESTIONS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
|
||||
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
|
||||
import { buildAbsoluteUrl } from '../helpers.js'
|
||||
import { buildAbsoluteUrl, getClientIp } from '../helpers.js'
|
||||
import { Resend } from 'resend'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
const upvoteRateLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many votes. Please try again later.' },
|
||||
})
|
||||
|
||||
// One upvote per client per question until the process restarts.
|
||||
const upvotedBy = new Set()
|
||||
const UPVOTED_BY_MAX = 50000
|
||||
|
||||
// Only these fields are ever sent to the public; the stored record also holds the asker's email.
|
||||
function toPublicQuestion(q) {
|
||||
return {
|
||||
id: q.id,
|
||||
firstName: q.firstName,
|
||||
question: q.question,
|
||||
answer: q.answer,
|
||||
topic: q.topic || '',
|
||||
reference: q.reference || '',
|
||||
submittedAt: q.submittedAt,
|
||||
answeredAt: q.answeredAt,
|
||||
upvotes: q.upvotes ?? 0,
|
||||
pinned: q.pinned === true,
|
||||
}
|
||||
}
|
||||
|
||||
const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
|
||||
|
||||
// Email the asker once when their answer goes live (if they asked to be told).
|
||||
async function notifyAsker(question) {
|
||||
if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = getResendFromAddress()
|
||||
const replyTo = getResendReplyToAddress()
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
|
||||
const template = buildQuestionAnsweredEmailTemplate({
|
||||
firstName: question.firstName,
|
||||
question: question.question,
|
||||
answer: question.answer,
|
||||
questionUrl,
|
||||
})
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
|
||||
context: `notify-on-answer:${question.id}`,
|
||||
})
|
||||
question.notifiedAt = new Date().toISOString()
|
||||
queueDraftQuestionsWrite()
|
||||
} catch (err) {
|
||||
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDraftQuestions() {
|
||||
if (state.draftQuestions !== null) return
|
||||
@@ -18,12 +76,14 @@ export function register(app) {
|
||||
const publicQuestions = sourceQuestions
|
||||
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||||
.sort((a, b) => {
|
||||
// Pinned questions always float to top
|
||||
// Pinned first (they are also the ones featured on the home page), then newest answer first
|
||||
if (a.pinned && !b.pinned) return -1
|
||||
if (!a.pinned && b.pinned) return 1
|
||||
return 0
|
||||
const at = new Date(a.answeredAt ?? a.submittedAt ?? 0).getTime() || 0
|
||||
const bt = new Date(b.answeredAt ?? b.submittedAt ?? 0).getTime() || 0
|
||||
return bt - at
|
||||
})
|
||||
res.json({ questions: publicQuestions })
|
||||
res.json({ questions: publicQuestions.map(toPublicQuestion) })
|
||||
})
|
||||
|
||||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||||
@@ -62,6 +122,8 @@ export function register(app) {
|
||||
answeredAt: answerText ? now : null,
|
||||
isApproved: approveNow,
|
||||
approvedAt: approveNow ? now : null,
|
||||
topic: clean(req.body?.topic, 60),
|
||||
reference: clean(req.body?.reference, 80),
|
||||
}
|
||||
|
||||
state.draftQuestions.unshift(created)
|
||||
@@ -106,32 +168,60 @@ export function register(app) {
|
||||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||||
queueDraftQuestionsWrite()
|
||||
|
||||
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = getResendFromAddress()
|
||||
const replyTo = getResendReplyToAddress()
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
|
||||
const template = buildQuestionAnsweredEmailTemplate({
|
||||
firstName: question.firstName,
|
||||
question: question.question,
|
||||
answer: question.answer,
|
||||
questionUrl,
|
||||
})
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
|
||||
context: `notify-on-answer:${question.id}`,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
|
||||
}
|
||||
}
|
||||
if (approved === true) await notifyAsker(question)
|
||||
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
// Edit any part of a question (typo fixes, answer, topic, scripture reference)
|
||||
app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === req.params.id)
|
||||
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||
|
||||
const body = req.body ?? {}
|
||||
if (typeof body.question === 'string') {
|
||||
const text = body.question.trim()
|
||||
if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return }
|
||||
question.question = text
|
||||
}
|
||||
if (typeof body.answer === 'string') {
|
||||
const text = body.answer.trim()
|
||||
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
|
||||
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
|
||||
question.answer = text
|
||||
}
|
||||
if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100)
|
||||
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
|
||||
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
|
||||
queueDraftQuestionsWrite()
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
// Save the answer, make it public and email the asker (once) — all in one step
|
||||
app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => {
|
||||
ensureDraftQuestions()
|
||||
const question = state.draftQuestions.find(q => q.id === req.params.id)
|
||||
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||
|
||||
const body = req.body ?? {}
|
||||
if (typeof body.answer === 'string') {
|
||||
const text = body.answer.trim()
|
||||
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
|
||||
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
|
||||
question.answer = text
|
||||
}
|
||||
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
|
||||
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
|
||||
if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return }
|
||||
|
||||
question.isApproved = true
|
||||
question.approvedAt = new Date().toISOString()
|
||||
queueDraftQuestionsWrite()
|
||||
await notifyAsker(question)
|
||||
res.json({ ok: true, question })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||||
const { id } = req.params
|
||||
ensureDraftQuestions()
|
||||
@@ -148,15 +238,22 @@ export function register(app) {
|
||||
})
|
||||
|
||||
// Anonymous upvote — no auth required, lightweight increment
|
||||
app.post('/api/questions/:id/upvote', (req, res) => {
|
||||
app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => {
|
||||
const { id } = req.params
|
||||
// Look in live questions first, then draft
|
||||
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
|
||||
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
|
||||
const question = liveQ ?? draftQ
|
||||
// Only approved questions are public, so only they can be upvoted.
|
||||
const source = state.draftQuestions ?? state.questions
|
||||
const question = source.find(q => q.id === id && q.isApproved === true)
|
||||
const liveQ = question && state.questions.includes(question) ? question : null
|
||||
const draftQ = question && state.draftQuestions?.includes(question) ? question : null
|
||||
if (!question) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
const voteKey = `${getClientIp(req)}|${id}`
|
||||
if (upvotedBy.has(voteKey)) {
|
||||
res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return
|
||||
}
|
||||
if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear()
|
||||
upvotedBy.add(voteKey)
|
||||
question.upvotes = ((question.upvotes ?? 0) + 1)
|
||||
if (liveQ) queueQuestionsWrite()
|
||||
if (draftQ) queueDraftQuestionsWrite()
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Resend } from 'resend'
|
||||
import { escapeHtml, sanitizeUrl } from '../helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { getResendFromAddress, getResendInboxAddress, getResendReplyToAddress, getCanonicalBaseUrl, sendResendEmailWithRetry } from '../email.js'
|
||||
|
||||
// A visitor who finished the /salvation page can tell us they prayed to receive Christ.
|
||||
// Nothing is stored on the server: the response is emailed once to the site inbox and that is all.
|
||||
const hits = new Map()
|
||||
// One confirmation per address per hour, so the form can't be used to mail someone repeatedly.
|
||||
const confirmedAt = new Map()
|
||||
const CONFIRM_COOLDOWN_MS = 60 * 60 * 1000
|
||||
|
||||
function mayConfirm(address) {
|
||||
const key = address.toLowerCase()
|
||||
const now = Date.now()
|
||||
const last = confirmedAt.get(key)
|
||||
if (typeof last === 'number' && now - last < CONFIRM_COOLDOWN_MS) return false
|
||||
confirmedAt.set(key, now)
|
||||
if (confirmedAt.size > 2000) {
|
||||
for (const [k, v] of confirmedAt) { if (now - v > CONFIRM_COOLDOWN_MS) confirmedAt.delete(k) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function rateLimit(req, res, next) {
|
||||
const ip = req.ip ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const windowMs = 10 * 60 * 1000
|
||||
const entry = hits.get(ip) ?? { count: 0, start: now }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
||||
entry.count += 1
|
||||
hits.set(ip, entry)
|
||||
if (hits.size > 2000) {
|
||||
for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) }
|
||||
}
|
||||
if (entry.count > 3) {
|
||||
res.status(429).json({ message: 'Please wait a few minutes before trying again.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function clean(value, max) {
|
||||
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
|
||||
}
|
||||
|
||||
|
||||
// The notification the site owner receives. Styled to match the other admin emails
|
||||
// (dark header, gold accent, cream cards) and written to be acted on, not just read.
|
||||
function buildSalvationEmail({ name, email, message, when }) {
|
||||
const who = name || 'Someone'
|
||||
const subject = name ? `A new decision for Christ: ${name}` : 'A new decision for Christ on your salvation page'
|
||||
const nameLine = name || 'Name not given'
|
||||
const safeName = escapeHtml(nameLine)
|
||||
const safeEmail = escapeHtml(email)
|
||||
const safeWhen = escapeHtml(when)
|
||||
|
||||
const followUp = email
|
||||
? [
|
||||
'Reply to this email within a day or two. A short, warm note is plenty.',
|
||||
'Congratulate them, and ask them to tell you their story.',
|
||||
'Point them to the Gospel of John, a Bible-teaching church, and baptism.',
|
||||
'Invite them to Start Here at versebyversewithnate.us/start-here.',
|
||||
'Pray for them by name.',
|
||||
]
|
||||
: ['They did not leave an email address, so there is no way to reply.', 'Pray for them. God knows exactly who they are.']
|
||||
|
||||
const text = [
|
||||
`A VERDICT HAS COME IN`,
|
||||
``,
|
||||
`${who} pleaded guilty and accepted Christ's pardon on the "The Gospel, in a Courtroom" page and says they prayed to receive Jesus as their Savior.`,
|
||||
``,
|
||||
`"There is joy in the presence of the angels of God over one sinner who repents." (Luke 15:10)`,
|
||||
``,
|
||||
`DETAILS`,
|
||||
`Name: ${nameLine}`,
|
||||
`Email: ${email || 'Not given'}`,
|
||||
`When: ${when}`,
|
||||
``,
|
||||
`THEIR MESSAGE`,
|
||||
message || '(They did not leave a message.)',
|
||||
``,
|
||||
`SUGGESTED FOLLOW-UP`,
|
||||
...followUp.map((t, i) => `${i + 1}. ${t}`),
|
||||
``,
|
||||
`This email is the only record of this response. Nothing is stored on the website, so keep it somewhere safe.`,
|
||||
email ? `Reply directly to this email to reach ${who}.` : '',
|
||||
].filter(l => l !== '').join('\n')
|
||||
|
||||
const row = (label, value, last = false) =>
|
||||
`<tr><td style="width:96px;padding:11px 0;font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a6d35;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${label}</td>` +
|
||||
`<td style="padding:11px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${value}</td></tr>`
|
||||
|
||||
const steps = followUp
|
||||
.map(t => `<li style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;">${escapeHtml(t)}</li>`)
|
||||
.join('')
|
||||
|
||||
const messageBlock = message
|
||||
? `<tr><td style="padding:0 28px 22px;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 8px;">Their message</div>
|
||||
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:16px 18px;font-family:Georgia,serif;font-size:16px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(message)}</div>
|
||||
</td></tr>`
|
||||
: `<tr><td style="padding:0 28px 22px;font-family:Arial,sans-serif;font-size:14px;color:#735a2b;font-style:italic;">They did not leave a message.</td></tr>`
|
||||
|
||||
const replyButton = email
|
||||
? `<tr><td align="center" style="padding:0 28px 26px;">
|
||||
<a href="mailto:${safeEmail}?subject=${encodeURIComponent('Welcome to the family of God')}" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 30px;border-radius:4px;">Reply to ${escapeHtml(who)}</a>
|
||||
</td></tr>`
|
||||
: ''
|
||||
|
||||
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:640px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||||
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">The Court of Heaven</div>
|
||||
<div style="font-size:40px;line-height:1;margin:14px 0 6px;color:#c8860a;">⚖</div>
|
||||
<h1 style="margin:0;font-family:Georgia,serif;font-size:30px;line-height:1.2;color:#f4ead5;">A Verdict Has Come In</h1>
|
||||
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">${escapeHtml(who)} pleaded guilty and accepted the pardon.</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:28px 28px 8px;">
|
||||
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Someone just worked through <em>The Gospel, in a Courtroom</em> on your website, pleaded guilty, and says they prayed to receive Jesus as their Savior.</p>
|
||||
<p style="margin:0 0 20px;font-family:Georgia,serif;font-style:italic;font-size:16px;line-height:1.6;color:#57452b;border-left:3px solid #c8860a;padding-left:14px;">“There is joy in the presence of the angels of God over one sinner who repents.” <span style="font-style:normal;font-size:13px;color:#8a6d35;">Luke 15:10</span></p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 28px 22px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border-top:2px solid #c8860a;">
|
||||
${row('Name', safeName)}
|
||||
${row('Email', email ? `<a href="mailto:${safeEmail}" style="color:#8f5f05;text-decoration:none;">${safeEmail}</a>` : '<span style="color:#735a2b;font-style:italic;">Not given</span>')}
|
||||
${row('When', safeWhen, true)}
|
||||
</table>
|
||||
</td></tr>
|
||||
${messageBlock}
|
||||
${replyButton}
|
||||
<tr><td style="padding:0 28px 26px;">
|
||||
<div style="background:#f7f2e5;border:1px solid #e8dcc1;border-radius:10px;padding:18px 22px;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 12px;">Suggested follow-up</div>
|
||||
<ol style="margin:0;padding-left:20px;">${steps}</ol>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;"><strong>Keep this email.</strong> It is the only record of this response. Nothing is stored on the website. ${email ? 'Replying to this message goes straight to them.' : ''}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table></body></html>`
|
||||
|
||||
return { subject, text, html }
|
||||
}
|
||||
|
||||
// The warm, one-time confirmation the visitor receives at the address they gave.
|
||||
function buildSalvationConfirmationEmail({ name }) {
|
||||
const base = getCanonicalBaseUrl().replace(/\/$/, '')
|
||||
const greeting = name ? `Hi ${name},` : 'Hi friend,'
|
||||
const subject = 'We received your message. Welcome to the family of God'
|
||||
const steps = [
|
||||
['Read the Gospel of John.', 'Start there, then keep going through the New Testament.'],
|
||||
['Talk to God every day.', 'Prayer is simply talking with him, in your own words.'],
|
||||
['Tell someone.', 'Share your decision with a Christian friend or family member.'],
|
||||
['Find a church.', 'Join a Bible-teaching church, and be baptized as a public sign of your faith (Acts 2:38).'],
|
||||
]
|
||||
|
||||
const text = [
|
||||
greeting,
|
||||
'',
|
||||
'Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.',
|
||||
'Someone from our team will reach out to you personally soon.',
|
||||
'',
|
||||
'"Therefore, there is now no condemnation for those who are in Christ Jesus." (Romans 8:1)',
|
||||
'You are no longer a defendant. You are a child of the Judge.',
|
||||
'',
|
||||
'WHAT TO DO NEXT',
|
||||
...steps.map(([h, b], i) => `${i + 1}. ${h} ${b}`),
|
||||
'',
|
||||
`A good path through the podcast and studies: ${base}/start-here`,
|
||||
`Questions? Ask here: ${base}/contact?source=salvation`,
|
||||
'',
|
||||
'Grace and peace,',
|
||||
'Verse by Verse with Nate',
|
||||
'',
|
||||
`You are receiving this one-time email because this address was entered on ${base}/salvation. If that was not you, you can safely ignore it.`,
|
||||
].join('\n')
|
||||
|
||||
const stepRows = steps
|
||||
.map(([h, b], i) =>
|
||||
`<tr><td valign="top" style="width:34px;padding:0 0 14px;"><div style="width:26px;height:26px;border-radius:13px;background:#c8860a;color:#111;font-family:Arial,sans-serif;font-size:13px;font-weight:700;line-height:26px;text-align:center;">${i + 1}</div></td>` +
|
||||
`<td style="padding:2px 0 14px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;"><strong>${escapeHtml(h)}</strong> ${escapeHtml(b)}</td></tr>`)
|
||||
.join('')
|
||||
|
||||
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:600px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||||
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
|
||||
<div style="width:54px;height:54px;border-radius:27px;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:30px;font-weight:700;line-height:54px;margin:16px auto 10px;">✓</div>
|
||||
<h1 style="margin:0;font-family:Georgia,serif;font-size:28px;line-height:1.25;color:#f4ead5;">Your message was received</h1>
|
||||
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">Welcome to the family of God.</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:30px 28px 6px;">
|
||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:18px;line-height:1.6;color:#201a10;">${escapeHtml(greeting)}</p>
|
||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.</p>
|
||||
<p style="margin:0 0 22px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;"><strong>Someone from our team will reach out to you personally soon.</strong></p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 28px 26px;">
|
||||
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:18px 20px;">
|
||||
<p style="margin:0 0 8px;font-family:Georgia,serif;font-style:italic;font-size:17px;line-height:1.6;color:#201a10;">“Therefore, there is now no condemnation for those who are in Christ Jesus.” <span style="font-style:normal;font-size:13px;color:#8a6d35;">Romans 8:1</span></p>
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#57452b;">You are no longer a defendant. You are a child of the Judge.</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 28px 8px;">
|
||||
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 14px;">What to do next</div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">${stepRows}</table>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:6px 28px 8px;">
|
||||
<a href="${escapeHtml(base)}/start-here" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 28px;border-radius:4px;">Start Here</a>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding:6px 28px 26px;font-family:Arial,sans-serif;font-size:14px;color:#57452b;">
|
||||
Have a question? <a href="${escapeHtml(base)}/contact?source=salvation" style="color:#8f5f05;">Ask us here</a>, or just reply to this email.
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 28px 28px;">
|
||||
<p style="margin:0;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#201a10;">Grace and peace,<br><strong>Verse by Verse with Nate</strong></p>
|
||||
</td></tr>
|
||||
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
|
||||
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;">You are receiving this one-time email because this address was entered at ${escapeHtml(base)}/salvation. If that was not you, you can safely ignore it.</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table></body></html>`
|
||||
|
||||
return { subject, text, html }
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// The /salvation page is static HTML, so it asks the server for the narration URLs set in Admin → Gospel Page.
|
||||
app.get('/api/salvation-audio', (_req, res) => {
|
||||
const c = state.cachedSiteContent ?? {}
|
||||
res.set('Cache-Control', 'public, max-age=300')
|
||||
res.json({
|
||||
url: sanitizeUrl(c.salvationAudioUrl) || '',
|
||||
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
|
||||
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
||||
const { name, email, message, _honey } = req.body ?? {}
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
const safeName = clean(name, 100)
|
||||
const safeEmail = clean(email, 200)
|
||||
const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : ''
|
||||
if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) {
|
||||
res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.error('[salvation] RESEND_API_KEY env var not set')
|
||||
res.status(503).json({ message: 'We could not send this right now.' })
|
||||
return
|
||||
}
|
||||
|
||||
const when = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' })
|
||||
const { subject, text, html } = buildSalvationEmail({ name: safeName, email: safeEmail, message: safeMessage, when })
|
||||
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'salvation-response',
|
||||
payload: {
|
||||
from: getResendFromAddress(),
|
||||
to: [getResendInboxAddress()],
|
||||
...(safeEmail ? { replyTo: safeEmail } : {}),
|
||||
subject,
|
||||
tags: [{ name: 'flow', value: 'salvation-response' }],
|
||||
text,
|
||||
html,
|
||||
},
|
||||
})
|
||||
|
||||
// Best effort: a failed or skipped confirmation must never turn a delivered decision into an error.
|
||||
let confirmationSent = false
|
||||
if (safeEmail && mayConfirm(safeEmail)) {
|
||||
try {
|
||||
const confirmation = buildSalvationConfirmationEmail({ name: safeName })
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'salvation-confirmation',
|
||||
payload: {
|
||||
from: getResendFromAddress(),
|
||||
to: [safeEmail],
|
||||
replyTo: getResendReplyToAddress(),
|
||||
subject: confirmation.subject,
|
||||
tags: [{ name: 'flow', value: 'salvation-confirmation' }],
|
||||
text: confirmation.text,
|
||||
html: confirmation.html,
|
||||
},
|
||||
})
|
||||
confirmationSent = true
|
||||
} catch (confirmErr) {
|
||||
console.error('[salvation] confirmation email failed:', confirmErr?.message ?? confirmErr)
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, confirmationSent })
|
||||
} catch (err) {
|
||||
console.error('[salvation] email failed:', err?.message ?? err)
|
||||
res.status(502).json({ message: 'We could not send this right now.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { noteEntriesToText } from '../../shared/note-format.js'
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl } from '../helpers.js'
|
||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS } from '../config.js'
|
||||
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName, escapeHtml, buildAbsoluteUrl, parseCookies } from '../helpers.js'
|
||||
import { UPLOADS_DIR, EMAIL_CHANGE_TOKEN_TTL_MS, MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTE_LENGTH, STUDY_SESSION_COOKIE } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
@@ -21,22 +22,27 @@ import {
|
||||
import {
|
||||
requireStudyAuth,
|
||||
hashStudyPassword,
|
||||
verifyStudyPassword,
|
||||
hashEmailChangeToken,
|
||||
normalizeStudyUsername,
|
||||
isValidStudyUsername,
|
||||
findStudyUserByUsername,
|
||||
getStudyAvatarUrl,
|
||||
isStudyUserEnrolled,
|
||||
isStudyUserCompleted,
|
||||
normalizeStudySlug,
|
||||
getStudyCatalog,
|
||||
clearStudySessionCookie,
|
||||
invalidateAllStudySessions,
|
||||
} from '../study-helpers.js'
|
||||
import {
|
||||
sendStudyAccountDeletedEmail,
|
||||
sendStudyPasswordChangedEmail,
|
||||
syncContactToResend,
|
||||
buildBrandedEmailHtml,
|
||||
getCanonicalBaseUrl,
|
||||
getResendFromAddress,
|
||||
sendResendEmailWithRetry,
|
||||
} from '../email.js'
|
||||
import { Resend } from 'resend'
|
||||
|
||||
@@ -82,7 +88,7 @@ export function register(app) {
|
||||
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
||||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||||
studyEntries[studySlug][sectionId].noteText = noteText.trim()
|
||||
studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
|
||||
}
|
||||
|
||||
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
||||
@@ -170,10 +176,7 @@ export function register(app) {
|
||||
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
@@ -186,6 +189,11 @@ export function register(app) {
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
invalidateAllStudySessions(user.id, parseCookies(req.headers.cookie)[STUDY_SESSION_COOKIE])
|
||||
|
||||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||
.catch(err => console.error('[study-account] password-changed email error:', err))
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -206,12 +214,23 @@ export function register(app) {
|
||||
title: study.title,
|
||||
status: study.status,
|
||||
enrolled: isStudyUserEnrolled(user, study.slug),
|
||||
completed: isStudyUserCompleted(user, study.slug),
|
||||
totalLessons,
|
||||
completedLessons,
|
||||
noteCount,
|
||||
}
|
||||
})
|
||||
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const certificates = state.studyCertificates
|
||||
.filter(c => c.userId === user.id)
|
||||
.map(c => ({
|
||||
studySlug: c.studySlug,
|
||||
studyTitle: c.studyTitle,
|
||||
issuedAt: c.issuedAt,
|
||||
url: buildAbsoluteUrl(baseUrl, `/certificate/${c.token}`),
|
||||
}))
|
||||
|
||||
res.json({
|
||||
profile: {
|
||||
username: user.username,
|
||||
@@ -221,9 +240,12 @@ export function register(app) {
|
||||
reminderFrequency: user.reminderFrequency ?? 'immediate',
|
||||
remindersPausedUntil: user.remindersPausedUntil ?? null,
|
||||
avatarUrl: getStudyAvatarUrl(user),
|
||||
emailVerified: user.emailVerified === true,
|
||||
},
|
||||
stats: {
|
||||
noteCount: Object.keys(notes).length,
|
||||
noteLimit: MAX_STUDY_NOTES_PER_USER,
|
||||
noteCharLimit: MAX_STUDY_NOTE_LENGTH,
|
||||
memberSince: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
currentStreak: user.currentStreak ?? 0,
|
||||
@@ -231,6 +253,7 @@ export function register(app) {
|
||||
lastStudiedDate: user.lastStudiedDate ?? null,
|
||||
},
|
||||
studies,
|
||||
certificates,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -356,10 +379,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentHash = hashStudyPassword(currentPassword)
|
||||
const a = Buffer.from(currentHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
if (!verifyStudyPassword(currentPassword, user.passwordHash)) {
|
||||
res.status(401).json({ message: 'Current password is incorrect.' })
|
||||
return
|
||||
}
|
||||
@@ -381,7 +401,10 @@ export function register(app) {
|
||||
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
|
||||
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
|
||||
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
|
||||
const { error } = await resend.emails.send({
|
||||
await sendResendEmailWithRetry({
|
||||
resend,
|
||||
context: 'study-account-email-change',
|
||||
payload: {
|
||||
from: getResendFromAddress(),
|
||||
to: [newEmail],
|
||||
subject: emailChangeSubject,
|
||||
@@ -394,14 +417,10 @@ export function register(app) {
|
||||
ctaUrl: verifyUrl,
|
||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||
}),
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
console.error('[study-account] email change send error:', error)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[study-account] email change send exception:', err)
|
||||
console.error('[study-account] email change send error:', err)
|
||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||
return
|
||||
}
|
||||
@@ -478,9 +497,7 @@ export function register(app) {
|
||||
const deletedEmail = user.username
|
||||
const deletedDisplayName = user.displayName || user.username
|
||||
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === user.id) state.studySessions.delete(token)
|
||||
}
|
||||
invalidateAllStudySessions(user.id)
|
||||
|
||||
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
|
||||
queueStudyUsersWrite()
|
||||
|
||||
+182
-18
@@ -6,12 +6,13 @@ import {
|
||||
verifyTotpCode,
|
||||
generateRecoveryCodes,
|
||||
} from '../auth.js'
|
||||
import { parseCookies } from '../helpers.js'
|
||||
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
||||
import { parseCookies, buildAbsoluteUrl } from '../helpers.js'
|
||||
import { STUDY_SESSION_COOKIE, MAX_STUDY_USERS, MAX_CONTACT_SUBMISSIONS, PASSWORD_RESET_TOKEN_TTL_MS, EMAIL_VERIFY_TOKEN_TTL_MS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
queueStudyUsersWrite,
|
||||
queueContactSubmissionsWrite,
|
||||
queueStudySessionsWrite,
|
||||
normalizeContactEmailStatus,
|
||||
normalizeMessageType,
|
||||
} from '../data.js'
|
||||
@@ -19,20 +20,36 @@ import {
|
||||
normalizeStudyUsername,
|
||||
isValidStudyUsername,
|
||||
hashStudyPassword,
|
||||
verifyStudyPassword,
|
||||
studyPasswordNeedsRehash,
|
||||
hashPasswordResetToken,
|
||||
hashEmailVerifyToken,
|
||||
findStudyUserByUsername,
|
||||
getStudyAvatarUrl,
|
||||
createStudySession,
|
||||
setStudySessionCookie,
|
||||
clearStudySessionCookie,
|
||||
requireStudyAuth,
|
||||
invalidateAllStudySessions,
|
||||
createStudyTotpPendingToken,
|
||||
consumeStudyTotpPendingToken,
|
||||
generateEmailOtp,
|
||||
storeEmailOtp,
|
||||
verifyEmailOtp,
|
||||
getStudyUserFromRequest,
|
||||
getAccessibleStudySlugs,
|
||||
} from '../study-helpers.js'
|
||||
import { sendEmailOtp, sendStudyWelcomeEmail, syncContactToResend } from '../email.js'
|
||||
import {
|
||||
sendEmailOtp,
|
||||
sendStudyWelcomeEmail,
|
||||
sendStudyVerifyEmail,
|
||||
sendStudyPasswordResetEmail,
|
||||
sendStudyPasswordChangedEmail,
|
||||
syncContactToResend,
|
||||
getCanonicalBaseUrl,
|
||||
} from '../email.js'
|
||||
|
||||
const DUMMY_PASSWORD_HASH = hashStudyPassword('timing-equalizer')
|
||||
|
||||
const studyAuthRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
@@ -53,7 +70,13 @@ export function register(app) {
|
||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
|
||||
// Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link
|
||||
// still lands on the admin sign-in, so it grants no access by itself.
|
||||
adminShortcut: user?.adminShortcut === true,
|
||||
// Studies the student can open: enrolled ones plus any they have finished
|
||||
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
||||
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||
emailVerified: user?.emailVerified === true,
|
||||
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
|
||||
twoFaMethod: user?.twoFaMethod ?? null,
|
||||
totpRecoveryCodesRemaining: user?.twoFaMethod === 'app' ? (user.totpRecoveryCodes?.length ?? 0) : 0,
|
||||
@@ -87,14 +110,21 @@ export function register(app) {
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const rawVerifyToken = randomUUID()
|
||||
const user = {
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: hashStudyPassword(password),
|
||||
displayName,
|
||||
subscribeNewsletter: subscribe,
|
||||
studyRemindersEnabled: false,
|
||||
studyRemindersEnabled: true,
|
||||
remindersDefaultedAt: now,
|
||||
pendingEmailChange: null,
|
||||
emailVerified: false,
|
||||
pendingEmailVerification: {
|
||||
tokenHash: hashEmailVerifyToken(rawVerifyToken),
|
||||
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
|
||||
},
|
||||
enrolledStudySlugs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -105,7 +135,6 @@ export function register(app) {
|
||||
queueStudyUsersWrite()
|
||||
|
||||
if (subscribe) {
|
||||
const wantsWelcome = true
|
||||
const submission = {
|
||||
id: randomUUID(),
|
||||
submittedAt: now,
|
||||
@@ -113,9 +142,9 @@ export function register(app) {
|
||||
email: username,
|
||||
message: '',
|
||||
messageType: 'general',
|
||||
subscribe: wantsWelcome,
|
||||
subscribe: true,
|
||||
archived: false,
|
||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
||||
emailStatus: normalizeContactEmailStatus(null, true),
|
||||
}
|
||||
state.contactSubmissions.unshift(submission)
|
||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||
@@ -124,6 +153,8 @@ export function register(app) {
|
||||
}
|
||||
|
||||
sendStudyWelcomeEmail(username, displayName || username).catch(err => console.error('[study-signup] welcome email error:', err))
|
||||
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawVerifyToken)}`)
|
||||
sendStudyVerifyEmail(username, displayName || username, verifyUrl).catch(err => console.error('[study-signup] verify email error:', err))
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
@@ -144,18 +175,19 @@ export function register(app) {
|
||||
const user = findStudyUserByUsername(username)
|
||||
|
||||
if (!user) {
|
||||
verifyStudyPassword(password, DUMMY_PASSWORD_HASH) // equalize timing with the real-user path
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const submittedHash = hashStudyPassword(password)
|
||||
const expectedHash = user.passwordHash
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(expectedHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
if (!verifyStudyPassword(password, user.passwordHash)) {
|
||||
res.status(401).json({ message: 'Invalid email or password.' })
|
||||
return
|
||||
}
|
||||
if (studyPasswordNeedsRehash(user.passwordHash)) {
|
||||
user.passwordHash = hashStudyPassword(password)
|
||||
queueStudyUsersWrite()
|
||||
}
|
||||
|
||||
const twoFaMethod = user.twoFaMethod ?? (user.totpSecret && user.totpVerified ? 'app' : null)
|
||||
if (twoFaMethod === 'app' && user.totpSecret && user.totpVerified) {
|
||||
@@ -186,7 +218,8 @@ export function register(app) {
|
||||
subscribeNewsletter: user.subscribeNewsletter !== false,
|
||||
studyRemindersEnabled: user.studyRemindersEnabled === true,
|
||||
avatarUrl: getStudyAvatarUrl(user.username),
|
||||
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
|
||||
enrolledStudySlugs: getAccessibleStudySlugs(user),
|
||||
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -322,10 +355,7 @@ export function register(app) {
|
||||
app.post('/api/study-auth/totp-disable', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const { password } = req.body ?? {}
|
||||
const submittedHash = hashStudyPassword(typeof password === 'string' ? password : '')
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(user.passwordHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
if (!verifyStudyPassword(typeof password === 'string' ? password : '', user.passwordHash)) {
|
||||
res.status(401).json({ message: 'Incorrect password.' })
|
||||
return
|
||||
}
|
||||
@@ -357,11 +387,145 @@ export function register(app) {
|
||||
const token = cookies[STUDY_SESSION_COOKIE]
|
||||
if (token) {
|
||||
state.studySessions.delete(token)
|
||||
queueStudySessionsWrite()
|
||||
}
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Signs out every other active session for the current user (this device stays signed in).
|
||||
app.post('/api/study-auth/logout-all', requireStudyAuth, (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const currentToken = cookies[STUDY_SESSION_COOKIE]
|
||||
invalidateAllStudySessions(req.studyUser.id, currentToken)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Request a password-reset email. Always responds ok to avoid leaking which emails have accounts.
|
||||
app.post('/api/study-auth/forgot-password', studyAuthRateLimiter, async (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
if (!isValidStudyUsername(username)) {
|
||||
res.status(400).json({ message: 'Please enter a valid email address.' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = findStudyUserByUsername(username)
|
||||
if (user) {
|
||||
const rawToken = randomUUID()
|
||||
user.pendingPasswordReset = {
|
||||
tokenHash: hashPasswordResetToken(rawToken),
|
||||
expiresAt: Date.now() + PASSWORD_RESET_TOKEN_TTL_MS,
|
||||
}
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
const resetUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/reset-password?token=${encodeURIComponent(rawToken)}`)
|
||||
sendStudyPasswordResetEmail(user.username, user.displayName || user.username, resetUrl)
|
||||
.catch(err => console.error('[study-forgot-password] send error:', err))
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: 'If that email has an account, a reset link is on its way.' })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/reset-password', studyAuthRateLimiter, (req, res) => {
|
||||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||||
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
|
||||
|
||||
if (!token) {
|
||||
res.status(400).json({ message: 'Invalid or missing reset token.' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||
res.status(400).json({ message: 'New password must be 8–200 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
const tokenHash = hashPasswordResetToken(token)
|
||||
const user = state.studyUsers.find(u => {
|
||||
const pending = u.pendingPasswordReset
|
||||
if (!pending?.tokenHash) return false
|
||||
const a = Buffer.from(tokenHash, 'utf8')
|
||||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ message: 'This reset link is invalid. Please request a new one.' })
|
||||
return
|
||||
}
|
||||
if (user.pendingPasswordReset.expiresAt <= Date.now()) {
|
||||
user.pendingPasswordReset = null
|
||||
queueStudyUsersWrite()
|
||||
res.status(400).json({ message: 'This reset link has expired. Please request a new one.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.passwordHash = hashStudyPassword(newPassword)
|
||||
user.pendingPasswordReset = null
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
invalidateAllStudySessions(user.id)
|
||||
|
||||
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
|
||||
.catch(err => console.error('[study-reset-password] confirmation email error:', err))
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Verify the email address used at signup. Not auth-gated so it works from any device/browser.
|
||||
app.post('/api/study-auth/verify-email', studyAuthRateLimiter, (req, res) => {
|
||||
const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''
|
||||
if (!token) {
|
||||
res.status(400).json({ message: 'Invalid or missing verification token.' })
|
||||
return
|
||||
}
|
||||
|
||||
const tokenHash = hashEmailVerifyToken(token)
|
||||
const user = state.studyUsers.find(u => {
|
||||
const pending = u.pendingEmailVerification
|
||||
if (!pending?.tokenHash) return false
|
||||
const a = Buffer.from(tokenHash, 'utf8')
|
||||
const b = Buffer.from(pending.tokenHash, 'utf8')
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ message: 'This verification link is invalid or has already been used.' })
|
||||
return
|
||||
}
|
||||
if (user.pendingEmailVerification.expiresAt <= Date.now()) {
|
||||
res.status(400).json({ message: 'This verification link has expired. Request a new one from My Account.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.emailVerified = true
|
||||
user.pendingEmailVerification = null
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/resend-verification', studyAuthRateLimiter, requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
if (user.emailVerified) {
|
||||
res.json({ ok: true, alreadyVerified: true })
|
||||
return
|
||||
}
|
||||
const rawToken = randomUUID()
|
||||
user.pendingEmailVerification = {
|
||||
tokenHash: hashEmailVerifyToken(rawToken),
|
||||
expiresAt: Date.now() + EMAIL_VERIFY_TOKEN_TTL_MS,
|
||||
}
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
|
||||
const verifyUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/verify-email?token=${encodeURIComponent(rawToken)}`)
|
||||
sendStudyVerifyEmail(user.username, user.displayName || user.username, verifyUrl)
|
||||
.catch(err => console.error('[study-resend-verification] send error:', err))
|
||||
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// One-click unsubscribe from re-engagement emails (no login required)
|
||||
app.get('/api/study-auth/unsubscribe-reminders', (req, res) => {
|
||||
const { uid, token } = req.query ?? {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, hasStudyAccess, getStudyTitleBySlug, getRequiredSectionIds, markStudyCompletedIfDone } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
|
||||
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
|
||||
@@ -15,19 +15,6 @@ const publicCertRateLimiter = rateLimit({
|
||||
message: { message: 'Too many certificate lookups. Please wait a moment.' },
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the list of required section IDs for a study (only released sections).
|
||||
*/
|
||||
function getRequiredSectionIds(studySlug) {
|
||||
const content = state.cachedSiteContent
|
||||
if (!content || !Array.isArray(content.studies)) return null
|
||||
const study = content.studies.find(s => s?.slug === studySlug)
|
||||
if (!study || !Array.isArray(study.sections)) return null
|
||||
return study.sections
|
||||
.filter(s => s?.status !== 'unreleased' && s?.id)
|
||||
.map(s => s.id)
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// GET — check eligibility and return existing certificate token if one exists
|
||||
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
|
||||
@@ -35,7 +22,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||
}
|
||||
|
||||
@@ -48,6 +35,8 @@ export function register(app) {
|
||||
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
|
||||
const eligible = requiredIds.every(id => completedIds.includes(id))
|
||||
|
||||
if (eligible) markStudyCompletedIfDone(user, studySlug, completedIds)
|
||||
|
||||
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
|
||||
|
||||
res.json({
|
||||
@@ -66,7 +55,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Not enrolled in this study.' }); return
|
||||
}
|
||||
|
||||
@@ -97,7 +86,7 @@ export function register(app) {
|
||||
}
|
||||
state.studyCertificates.push(cert)
|
||||
queueStudyCertificatesWrite()
|
||||
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`)
|
||||
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/certificate/${cert.token}`)
|
||||
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
|
||||
.catch(err => console.error('[study-certificate] email exception:', err))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled } from '../study-helpers.js'
|
||||
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName, isStudyDiscussionOpen, DISCUSSION_CLOSED_MESSAGE } from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueStudyCommentsWrite } from '../data.js'
|
||||
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
|
||||
@@ -14,15 +14,22 @@ export function register(app) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.json({ comments: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE }); return
|
||||
}
|
||||
|
||||
const comments = state.studyComments
|
||||
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
|
||||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
.map(c => ({
|
||||
.map(c => {
|
||||
const author = findStudyUserById(c.userId)
|
||||
return {
|
||||
id: c.id,
|
||||
displayName: c.displayName,
|
||||
displayName: author ? getCommunityDisplayName(author) : c.displayName,
|
||||
text: c.text,
|
||||
createdAt: c.createdAt,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ comments })
|
||||
})
|
||||
@@ -37,10 +44,14 @@ export function register(app) {
|
||||
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
|
||||
}
|
||||
|
||||
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
|
||||
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
|
||||
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
|
||||
@@ -53,7 +64,7 @@ export function register(app) {
|
||||
studySlug,
|
||||
sectionId,
|
||||
userId: user.id,
|
||||
displayName: user.displayName || user.username,
|
||||
displayName: getCommunityDisplayName(user),
|
||||
text,
|
||||
createdAt: now,
|
||||
isApproved: true,
|
||||
|
||||
+61
-21
@@ -3,12 +3,17 @@ import {
|
||||
normalizeStudySlug,
|
||||
normalizeLessonSectionId,
|
||||
getStudySlugFromNoteId,
|
||||
isStudyUserEnrolled,
|
||||
hasStudyAccess,
|
||||
isEnrollableStudySlug,
|
||||
getStudyTitleBySlug,
|
||||
findStudyUserById,
|
||||
getStudyAvatarUrl,
|
||||
getStudyCatalog,
|
||||
getCommunityDisplayName,
|
||||
getAccessibleStudySlugs,
|
||||
markStudyCompletedIfDone,
|
||||
isStudyDiscussionOpen,
|
||||
DISCUSSION_CLOSED_MESSAGE,
|
||||
} from '../study-helpers.js'
|
||||
import { state } from '../state.js'
|
||||
import {
|
||||
@@ -58,6 +63,11 @@ export function register(app) {
|
||||
|
||||
const enrolled = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
if (!enrolled.includes(studySlug)) {
|
||||
const catalogEntry = getStudyCatalog().find(study => study.slug === studySlug)
|
||||
if (catalogEntry?.status === 'planned') {
|
||||
res.status(400).json({ message: 'This study is not open for enrollment yet.' })
|
||||
return
|
||||
}
|
||||
user.enrolledStudySlugs = [...enrolled, studySlug].slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
@@ -67,7 +77,8 @@ export function register(app) {
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
enrolledStudySlugs: getAccessibleStudySlugs(user),
|
||||
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,7 +101,8 @@ export function register(app) {
|
||||
ok: true,
|
||||
studySlug,
|
||||
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
|
||||
enrolledStudySlugs: user.enrolledStudySlugs,
|
||||
enrolledStudySlugs: getAccessibleStudySlugs(user),
|
||||
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -110,7 +122,7 @@ export function register(app) {
|
||||
}
|
||||
const user = req.studyUser
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
|
||||
return
|
||||
}
|
||||
@@ -130,12 +142,16 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
const noteStudySlug = getStudySlugFromNoteId(sectionId)
|
||||
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
|
||||
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
|
||||
return
|
||||
}
|
||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||
const note = rawNote.trim()
|
||||
if (note.length > MAX_STUDY_NOTE_LENGTH) {
|
||||
res.status(413).json({ message: 'This lesson\'s notes are too long to save. Please trim some text.' })
|
||||
return
|
||||
}
|
||||
const notes = await loadUserNotes(user.id)
|
||||
|
||||
if (!note) {
|
||||
@@ -164,7 +180,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
|
||||
return
|
||||
}
|
||||
@@ -191,7 +207,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||
return
|
||||
}
|
||||
@@ -221,8 +237,9 @@ export function register(app) {
|
||||
user.firstCompletionAt = new Date().toISOString()
|
||||
}
|
||||
queueStudyUsersWrite()
|
||||
const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds)
|
||||
|
||||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
|
||||
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined })
|
||||
})
|
||||
|
||||
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
@@ -234,7 +251,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
|
||||
return
|
||||
}
|
||||
@@ -261,7 +278,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
|
||||
return
|
||||
}
|
||||
@@ -280,7 +297,7 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
|
||||
return
|
||||
}
|
||||
@@ -309,7 +326,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
||||
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
|
||||
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||
@@ -329,7 +346,7 @@ export function register(app) {
|
||||
const studySlug = normalizeStudySlug(req.params.studySlug)
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
|
||||
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
|
||||
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
|
||||
|
||||
const progress = await loadUserProgress(user.id)
|
||||
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
|
||||
@@ -352,11 +369,16 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.json({ studySlug, posts: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE })
|
||||
return
|
||||
}
|
||||
|
||||
const posts = state.studyCommunityPosts
|
||||
.filter(post => post.studySlug === studySlug)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
@@ -366,13 +388,18 @@ export function register(app) {
|
||||
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
|
||||
return {
|
||||
...post,
|
||||
authorName: author ? getCommunityDisplayName(author) : post.authorName,
|
||||
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
|
||||
likedByMe: likedBy.includes(user.id),
|
||||
likeCount: likedBy.length,
|
||||
replies: Array.isArray(post.replies)
|
||||
? post.replies.map(reply => {
|
||||
const replyAuthor = findStudyUserById(reply.authorUserId)
|
||||
return { ...reply, authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || '') }
|
||||
return {
|
||||
...reply,
|
||||
authorName: replyAuthor ? getCommunityDisplayName(replyAuthor) : reply.authorName,
|
||||
authorAvatarUrl: getStudyAvatarUrl(replyAuthor || reply.authorName || ''),
|
||||
}
|
||||
})
|
||||
: [],
|
||||
}
|
||||
@@ -392,13 +419,18 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
if (!hasStudyAccess(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const authorName = user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username)
|
||||
const authorName = getCommunityDisplayName(user)
|
||||
const post = {
|
||||
id: randomUUID(),
|
||||
studySlug,
|
||||
@@ -434,15 +466,20 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||||
if (!hasStudyAccess(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyDiscussionOpen(post.studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
|
||||
return
|
||||
}
|
||||
|
||||
const reply = {
|
||||
id: randomUUID(),
|
||||
authorUserId: user.id,
|
||||
authorName: user.displayName?.trim() || (user.username?.includes('@') ? user.username.split('@')[0] : user.username),
|
||||
authorName: getCommunityDisplayName(user),
|
||||
authorAvatarUrl: getStudyAvatarUrl(user.username),
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -461,9 +498,12 @@ export function register(app) {
|
||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
||||
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
|
||||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||||
if (!hasStudyAccess(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
|
||||
}
|
||||
if (!isStudyDiscussionOpen(post.studySlug)) {
|
||||
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
|
||||
}
|
||||
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
||||
const idx = post.likedBy.indexOf(user.id)
|
||||
if (idx === -1) {
|
||||
|
||||
+15
-2
@@ -6,6 +6,19 @@ export function extractCdata(raw) {
|
||||
return cdata ? cdata[1].trim() : raw.trim()
|
||||
}
|
||||
|
||||
function decodeEntities(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
|
||||
.replace(/ /g, ' ')
|
||||
}
|
||||
|
||||
export function parseRssItems(xml, limit = Infinity) {
|
||||
const items = []
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g
|
||||
@@ -22,14 +35,14 @@ export function parseRssItems(xml, limit = Infinity) {
|
||||
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
|
||||
const link = guid.startsWith('http') ? guid : enclosureUrl
|
||||
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
|
||||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
const descText = decodeEntities(extractCdata(descRaw).replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim()
|
||||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||||
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
|
||||
items.push({
|
||||
title, pubDate, link,
|
||||
audioUrl: enclosureUrl,
|
||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||||
description: descText.slice(0, 800) + (descText.length > 800 ? '…' : ''),
|
||||
duration, episode, season,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import dns from 'node:dns'
|
||||
import { SEAFILE_URL } from './config.js'
|
||||
|
||||
// Alpine's resolver (musl) fails the whole lookup when a DNS server answers the IPv6 (AAAA)
|
||||
// query with "no such name" even though the IPv4 answer is fine, so Node reports ENOTFOUND for
|
||||
// a host that resolves. Ask for IPv4 only, and only for the Seafile host.
|
||||
export function preferIpv4ForSeafile() {
|
||||
let host = ''
|
||||
try {
|
||||
host = SEAFILE_URL ? new URL(SEAFILE_URL).hostname : ''
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!host) return
|
||||
|
||||
const originalLookup = dns.lookup
|
||||
dns.lookup = function lookup(hostname, options, callback) {
|
||||
if (hostname === host) {
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
options = {}
|
||||
} else if (typeof options === 'number') {
|
||||
options = { family: options }
|
||||
}
|
||||
if (!options?.family) options = { ...options, family: 4 }
|
||||
}
|
||||
return originalLookup.call(this, hostname, options, callback)
|
||||
}
|
||||
console.log(`[seafile-dns] resolving ${host} over IPv4 only`)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { state } from './state.js'
|
||||
import { requireAdminAuth } from './auth.js'
|
||||
import { scriptKey } from './episode-script-keys.js'
|
||||
import { getFeedIndex } from './feed-release.js'
|
||||
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||
|
||||
// One report per lane (A = primary series, B = second series) listing what is set up, what is
|
||||
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
|
||||
// publishing. Read-only: it never changes anything.
|
||||
|
||||
const err = (message, view) => ({ severity: 'error', message, view })
|
||||
const warn = (message, view) => ({ severity: 'warn', message, view })
|
||||
|
||||
function laneConfig(content, lane) {
|
||||
if (lane === 'A') {
|
||||
return {
|
||||
title: String(content.seriesTitle ?? '').trim(),
|
||||
studySlug: String(content.seriesStudySlug ?? '').trim(),
|
||||
highlightLane: 'primary',
|
||||
description: String(content.seriesDescription ?? '').trim(),
|
||||
imageUrl: String(content.seriesImageUrl ?? '').trim(),
|
||||
listenUrl: String(content.seriesListenUrl ?? '').trim(),
|
||||
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
|
||||
transition: content.scheduledSeriesTransition,
|
||||
}
|
||||
}
|
||||
const second = content.secondSeries ?? {}
|
||||
return {
|
||||
title: String(second.title ?? '').trim(),
|
||||
studySlug: String(second.studySlug ?? '').trim(),
|
||||
highlightLane: 'second',
|
||||
description: String(second.description ?? '').trim(),
|
||||
imageUrl: String(second.imageUrl ?? '').trim(),
|
||||
listenUrl: String(second.listenUrl ?? '').trim(),
|
||||
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
|
||||
transition: content.scheduledSecondSeriesTransition,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSeasonCheck() {
|
||||
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
|
||||
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
|
||||
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
|
||||
|
||||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
|
||||
|
||||
const lanes = []
|
||||
for (const lane of ['A', 'B']) {
|
||||
const cfg = laneConfig(content, lane)
|
||||
// Lane B only exists once a second series has been set up
|
||||
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
|
||||
|
||||
const season = seasons[lane]
|
||||
const problems = []
|
||||
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
|
||||
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
|
||||
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
|
||||
|
||||
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
|
||||
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
|
||||
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
|
||||
|
||||
const sections = Array.isArray(study?.sections) ? study.sections : []
|
||||
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
|
||||
|
||||
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
|
||||
if (study && noNumber.length > 0) {
|
||||
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
|
||||
}
|
||||
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
|
||||
if (study && noDate.length > 0) {
|
||||
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
|
||||
}
|
||||
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
|
||||
if (noHighlightNumber.length > 0) {
|
||||
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
|
||||
}
|
||||
|
||||
// Series card
|
||||
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
|
||||
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
|
||||
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
|
||||
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
|
||||
|
||||
// Handover to the next season
|
||||
const t = cfg.transition
|
||||
if (t) {
|
||||
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
|
||||
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
|
||||
} else if (season && String(t.newSeason) === season) {
|
||||
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
|
||||
}
|
||||
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
|
||||
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
|
||||
}
|
||||
|
||||
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
|
||||
const numbers = new Set()
|
||||
for (let n = 1; n <= sections.length; n++) numbers.add(n)
|
||||
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
|
||||
sections.forEach(s => addNumber(s.episodeNumber))
|
||||
laneHighlights.forEach(h => addNumber(h.episodeNumber))
|
||||
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
|
||||
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
|
||||
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
|
||||
|
||||
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
|
||||
const num = String(n)
|
||||
const feedItem = season ? index.get(`${season}-${num}`) : undefined
|
||||
const released = Boolean(feedItem)
|
||||
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
|
||||
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
|
||||
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
|
||||
|
||||
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
|
||||
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
|
||||
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
|
||||
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
|
||||
}
|
||||
|
||||
return {
|
||||
number: n,
|
||||
released,
|
||||
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
|
||||
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
|
||||
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
|
||||
lesson: lessons.length > 0,
|
||||
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
|
||||
highlight: hl.length > 0,
|
||||
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
|
||||
}
|
||||
})
|
||||
|
||||
lanes.push({
|
||||
lane,
|
||||
title: cfg.title,
|
||||
season,
|
||||
studyTitle: study?.title ?? '',
|
||||
problems,
|
||||
errors: problems.filter(p => p.severity === 'error').length,
|
||||
warnings: problems.filter(p => p.severity === 'warn').length,
|
||||
episodes,
|
||||
})
|
||||
}
|
||||
|
||||
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
|
||||
const general = []
|
||||
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
|
||||
if (unseasoned.length > 0) {
|
||||
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
|
||||
}
|
||||
|
||||
const all = [...lanes.flatMap(l => l.problems), ...general]
|
||||
return {
|
||||
lanes,
|
||||
general,
|
||||
total: all.length,
|
||||
errors: all.filter(p => p.severity === 'error').length,
|
||||
warnings: all.filter(p => p.severity === 'warn').length,
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
|
||||
try {
|
||||
res.json(await buildSeasonCheck())
|
||||
} catch (err) {
|
||||
console.error('[season-check] failed:', err)
|
||||
res.status(500).json({ message: 'Could not build the season check.' })
|
||||
}
|
||||
})
|
||||
}
|
||||
+12
-6
@@ -3,10 +3,11 @@ import {
|
||||
EMPTY_VISITOR_STATS,
|
||||
DEFAULT_PUBLISH_STATE,
|
||||
DEFAULT_REPLY_TEMPLATES,
|
||||
buildDefaultPodcastChecklist,
|
||||
} from './config.js'
|
||||
|
||||
export const state = {
|
||||
serverStartedAt: new Date().toISOString(),
|
||||
|
||||
cachedSiteContent: null,
|
||||
cachedDraftSiteContent: null,
|
||||
publishState: { ...DEFAULT_PUBLISH_STATE },
|
||||
@@ -34,8 +35,6 @@ export const state = {
|
||||
replyHistory: [],
|
||||
replyHistoryWritePromise: Promise.resolve(),
|
||||
|
||||
podcastChecklist: buildDefaultPodcastChecklist(),
|
||||
podcastChecklistWritePromise: Promise.resolve(),
|
||||
|
||||
studyUsers: [],
|
||||
studyUsersWritePromise: Promise.resolve(),
|
||||
@@ -52,7 +51,7 @@ export const state = {
|
||||
studyCertificates: [],
|
||||
studyCertificatesWritePromise: Promise.resolve(),
|
||||
|
||||
// episodeScripts: { [episodeNumber]: { text, title, filename, uploadedAt } }
|
||||
// episodeScripts: { ["<season>-<episode>" or "<episode>" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } }
|
||||
episodeScripts: {},
|
||||
episodeScriptsWritePromise: Promise.resolve(),
|
||||
|
||||
@@ -81,6 +80,7 @@ export const state = {
|
||||
searchQueries: {},
|
||||
notFound: {},
|
||||
audioEvents: {},
|
||||
linkClicks: {},
|
||||
},
|
||||
analyticsEventsWritePromise: Promise.resolve(),
|
||||
|
||||
@@ -93,8 +93,13 @@ export const state = {
|
||||
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
||||
emailSettingsWritePromise: Promise.resolve(),
|
||||
|
||||
calendarEvents: [],
|
||||
calendarEventsWritePromise: Promise.resolve(),
|
||||
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||
newsletterIssues: [],
|
||||
newsletterIssuesWritePromise: Promise.resolve(),
|
||||
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
|
||||
nuggetsEpisodeTags: {},
|
||||
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
auditLog: [],
|
||||
auditLogWritePromise: Promise.resolve(),
|
||||
@@ -113,6 +118,7 @@ export const state = {
|
||||
studyProgressWriteQueues: new Map(), // userId -> Promise
|
||||
|
||||
studySessions: new Map(),
|
||||
studySessionsWritePromise: Promise.resolve(),
|
||||
studyTotpPendingTokens: new Map(),
|
||||
emailOtpStore: new Map(),
|
||||
|
||||
|
||||
+234
-87
@@ -1,6 +1,15 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
||||
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { parseCookies, cookieSecureFlag } from './helpers.js'
|
||||
import {
|
||||
parseCookies,
|
||||
cookieSecureFlag,
|
||||
normalizeIp,
|
||||
isPrivateOrLocalIp,
|
||||
normalizeRedirectPath,
|
||||
sanitizeUrl,
|
||||
sanitizeRedirectRules,
|
||||
} from './helpers.js'
|
||||
export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js'
|
||||
import {
|
||||
STUDY_SESSION_COOKIE,
|
||||
STUDY_SESSION_TTL_MS,
|
||||
@@ -9,9 +18,11 @@ import {
|
||||
EMAIL_OTP_MAX_ATTEMPTS,
|
||||
CONTACT_EMAIL_COOLDOWN_MS,
|
||||
DEFAULT_REDIRECT_RULES,
|
||||
VISITOR_RETENTION_DAYS_DEFAULT,
|
||||
DOWNLOAD_TOKEN_TTL_MS,
|
||||
} from './config.js'
|
||||
import { state } from './state.js'
|
||||
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
|
||||
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
|
||||
|
||||
// ── Username / slug normalizers ────────────────────────────────────────────
|
||||
|
||||
@@ -45,16 +56,68 @@ export function getStudySlugFromNoteId(sectionId) {
|
||||
|
||||
// ── Password / token hashing ───────────────────────────────────────────────
|
||||
|
||||
export function hashStudyPassword(password) {
|
||||
const LEGACY_HASH_RE = /^[0-9a-f]{64}$/
|
||||
|
||||
function legacyHashStudyPassword(password) {
|
||||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||||
}
|
||||
|
||||
// New hashes are `scrypt$<salt hex>$<hash hex>`. Legacy accounts hold an
|
||||
// unsalted SHA-256 hex digest; verifyStudyPassword accepts both and
|
||||
// studyPasswordNeedsRehash lets login upgrade them transparently.
|
||||
export function hashStudyPassword(password) {
|
||||
const salt = randomBytes(16)
|
||||
const hash = scryptSync(String(password), salt, 64)
|
||||
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
|
||||
}
|
||||
|
||||
export function verifyStudyPassword(password, stored) {
|
||||
if (typeof stored !== 'string' || !stored) return false
|
||||
let expected
|
||||
let actual
|
||||
if (stored.startsWith('scrypt$')) {
|
||||
const [, saltHex, hashHex] = stored.split('$')
|
||||
if (!saltHex || !hashHex) return false
|
||||
expected = Buffer.from(hashHex, 'hex')
|
||||
actual = scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length)
|
||||
} else if (LEGACY_HASH_RE.test(stored)) {
|
||||
expected = Buffer.from(stored, 'hex')
|
||||
actual = Buffer.from(legacyHashStudyPassword(password), 'hex')
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||
}
|
||||
|
||||
export function studyPasswordNeedsRehash(stored) {
|
||||
return typeof stored === 'string' && !stored.startsWith('scrypt$')
|
||||
}
|
||||
|
||||
export function hashEmailChangeToken(token) {
|
||||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
export function hashPasswordResetToken(token) {
|
||||
return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
export function hashEmailVerifyToken(token) {
|
||||
return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||
|
||||
// A study is 'complete' when an admin marks it so, or when it has lessons and every
|
||||
// one of them has already been released. Planned studies never auto-complete.
|
||||
export function getEffectiveStudyStatus(study) {
|
||||
if (!study || typeof study !== 'object') return 'active'
|
||||
if (study.status === 'planned') return 'planned'
|
||||
if (study.status === 'complete') return 'complete'
|
||||
const sections = Array.isArray(study.sections) ? study.sections : []
|
||||
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
export function getStudyCatalog() {
|
||||
const fallback = [
|
||||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||
@@ -72,7 +135,7 @@ export function getStudyCatalog() {
|
||||
out.push({
|
||||
slug,
|
||||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||
status: study?.status === 'planned' ? 'planned' : 'active',
|
||||
status: getEffectiveStudyStatus(study),
|
||||
})
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
@@ -100,6 +163,81 @@ export function isStudyUserEnrolled(user, studySlug) {
|
||||
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
||||
}
|
||||
|
||||
// A student who has finished every lesson in a study keeps access to it (lessons, notes,
|
||||
// certificate) even if they later leave the enrollment. Completion is stored on the user
|
||||
// so it survives content edits and un-enrolling.
|
||||
export function isStudyUserCompleted(user, studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
if (!normalized || !user) return false
|
||||
return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized)
|
||||
}
|
||||
|
||||
export function hasStudyAccess(user, studySlug) {
|
||||
return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug)
|
||||
}
|
||||
|
||||
// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of
|
||||
// studies the student can open.
|
||||
export function getAccessibleStudySlugs(user) {
|
||||
const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : []
|
||||
return [...new Set([...enrolled, ...completed])]
|
||||
}
|
||||
|
||||
// Every lesson (released or not) is required, matching the certificate rules.
|
||||
export function getRequiredSectionIds(studySlug) {
|
||||
const content = state.cachedSiteContent
|
||||
if (!content || !Array.isArray(content.studies)) return null
|
||||
const study = content.studies.find(item => item?.slug === studySlug)
|
||||
if (!study || !Array.isArray(study.sections)) return null
|
||||
return study.sections.filter(item => item?.id).map(item => item.id)
|
||||
}
|
||||
|
||||
// Marks the study completed on the user when every required lesson is done. Sticky: never
|
||||
// removed, so a later new lesson doesn't revoke access. Returns true if newly completed.
|
||||
export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
if (!normalized || isStudyUserCompleted(user, normalized)) return false
|
||||
const required = getRequiredSectionIds(normalized)
|
||||
if (!required || required.length === 0) return false
|
||||
if (!required.every(id => completedSectionIds.includes(id))) return false
|
||||
user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized]
|
||||
user.updatedAt = new Date().toISOString()
|
||||
queueStudyUsersWrite()
|
||||
return true
|
||||
}
|
||||
|
||||
// One-off pass at startup so students who finished a study before this existed get the flag.
|
||||
export async function backfillCompletedStudies() {
|
||||
if (!state.cachedSiteContent) return 0
|
||||
let updated = 0
|
||||
for (const user of state.studyUsers) {
|
||||
const progress = await loadUserProgress(user.id)
|
||||
// Enrolled studies plus any with saved progress, so students who already left are caught too
|
||||
const slugs = new Set([
|
||||
...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []),
|
||||
...Object.keys(progress.byStudy ?? {}),
|
||||
])
|
||||
for (const slug of slugs) {
|
||||
if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// Discussion (community posts, replies, likes and lesson comments) is open for any study that
|
||||
// has started: the current study and finished ones, so students who take a study later can
|
||||
// still talk with each other. Only planned studies (not started yet) are closed.
|
||||
export function isStudyDiscussionOpen(studySlug) {
|
||||
const normalized = normalizeStudySlug(studySlug)
|
||||
const studies = state.cachedSiteContent?.studies
|
||||
if (!normalized || !Array.isArray(studies)) return false
|
||||
const study = studies.find(item => normalizeStudySlug(item?.slug) === normalized)
|
||||
return Boolean(study) && getEffectiveStudyStatus(study) !== 'planned'
|
||||
}
|
||||
|
||||
export const DISCUSSION_CLOSED_MESSAGE = 'Discussion opens once this study has started.'
|
||||
|
||||
export function findStudyUserById(userId) {
|
||||
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
||||
return state.studyUsers.find(user => user.id === userId)
|
||||
@@ -109,6 +247,17 @@ export function findStudyUserByUsername(username) {
|
||||
return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
||||
}
|
||||
|
||||
// ── Community display name ──────────────────────────────────────────────────
|
||||
|
||||
// Never derived from the student's email — falls back to a stable, anonymous
|
||||
// handle assigned from their account id so nobody's address leaks into posts.
|
||||
export function getCommunityDisplayName(user) {
|
||||
const displayName = typeof user?.displayName === 'string' ? user.displayName.trim() : ''
|
||||
if (displayName) return displayName
|
||||
const id = typeof user?.id === 'string' ? user.id : ''
|
||||
return `Student ${(id.replace(/-/g, '').slice(0, 4) || '0000').toUpperCase()}`
|
||||
}
|
||||
|
||||
// ── Avatar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getStudyAvatarUrl(subject) {
|
||||
@@ -130,27 +279,36 @@ export function getStudyAvatarUrl(subject) {
|
||||
|
||||
// ── Session management ─────────────────────────────────────────────────────
|
||||
|
||||
export function cookieFlags() {
|
||||
return cookieSecureFlag()
|
||||
}
|
||||
|
||||
export function createStudySession(userId) {
|
||||
const token = randomUUID()
|
||||
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||
queueStudySessionsWrite()
|
||||
return token
|
||||
}
|
||||
|
||||
// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere").
|
||||
export function invalidateAllStudySessions(userId, exceptToken = null) {
|
||||
let changed = false
|
||||
for (const [token, session] of state.studySessions) {
|
||||
if (session.userId === userId && token !== exceptToken) {
|
||||
state.studySessions.delete(token)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) queueStudySessionsWrite()
|
||||
}
|
||||
|
||||
export function setStudySessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearStudySessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,7 +391,7 @@ export function createTitusDownloadToken(email) {
|
||||
const token = randomUUID()
|
||||
state.titusDownloadTokens.set(token, {
|
||||
email,
|
||||
expiresAt: Date.now() + (10 * 60 * 1000),
|
||||
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
|
||||
})
|
||||
return token
|
||||
}
|
||||
@@ -273,9 +431,11 @@ export function isSectionReleased(section) {
|
||||
|
||||
export function redactUnreleasedSection(section) {
|
||||
if (!section || typeof section !== 'object') return section
|
||||
if (isSectionReleased(section)) return section
|
||||
// episodeNumber is an admin-only reference and is never sent to the public site
|
||||
const { episodeNumber: _episodeNumber, ...publicSection } = section
|
||||
if (isSectionReleased(section)) return publicSection
|
||||
return {
|
||||
...section,
|
||||
...publicSection,
|
||||
passageText: '',
|
||||
commentary: '',
|
||||
greekNotes: [],
|
||||
@@ -316,6 +476,21 @@ export function normalizeHitPath(pathname) {
|
||||
return pathname
|
||||
}
|
||||
|
||||
// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or
|
||||
// scanner hit, which must not count as a real visit or add a new key to the per-path stats.
|
||||
const KNOWN_SITE_SEGMENTS = new Set([
|
||||
'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact',
|
||||
'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview',
|
||||
'privacy', 'terms', 'salvation', 'qr',
|
||||
])
|
||||
|
||||
const MAX_UNKNOWN_PATHS = 300
|
||||
|
||||
export function isKnownSitePath(pathname) {
|
||||
const first = String(pathname ?? '/').split('/')[1] ?? ''
|
||||
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
|
||||
}
|
||||
|
||||
export function shouldCountHit(req) {
|
||||
if (req.method !== 'GET') return false
|
||||
if (req.path.startsWith('/api/')) return false
|
||||
@@ -374,36 +549,19 @@ export function detectBot(userAgent) {
|
||||
return { isBot: true, reason: 'http-client' }
|
||||
}
|
||||
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
|
||||
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
|
||||
// Generic keywords like "reader" or "agent" can appear as substrings of
|
||||
// otherwise-normal browser UAs (e.g. some webviews, regional browsers).
|
||||
// Any UA carrying a real rendering-engine or vendor token is treated as
|
||||
// a genuine browser rather than a bot, even if it also matches a keyword
|
||||
// above — this list should stay a superset of the previous one so it
|
||||
// only ever reduces false positives, never re-admits an actual bot.
|
||||
if (!/chrome|firefox|safari|opera|edge|msie|trident|gecko|webkit|khtml|presto|crios|fxios|ucbrowser|samsungbrowser/.test(ua)) {
|
||||
return { isBot: true, reason: 'bot-keyword' }
|
||||
}
|
||||
}
|
||||
return { isBot: false, reason: 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 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'
|
||||
)
|
||||
}
|
||||
|
||||
export function buildTopLocations(list, key) {
|
||||
const counts = {}
|
||||
for (const row of list) {
|
||||
@@ -437,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
|
||||
state.hitStats.lastHitAt = nowIso
|
||||
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
|
||||
|
||||
if (isBot && botReason === 'unknown-path') {
|
||||
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
|
||||
const unknown = (state.hitStats.byPathUnknown ??= {})
|
||||
const key = safePath.slice(0, 120)
|
||||
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
|
||||
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
|
||||
state.hitStats.botHits += 1
|
||||
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
|
||||
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
|
||||
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
|
||||
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
|
||||
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
|
||||
return
|
||||
}
|
||||
|
||||
if (isBot) {
|
||||
state.hitStats.botHits += 1
|
||||
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
|
||||
@@ -455,7 +628,6 @@ export function recordHit(pathname, isBot = false, botReason = null) {
|
||||
}
|
||||
|
||||
export function pruneStatsByDays(daysRaw) {
|
||||
const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
|
||||
const days = Number(daysRaw)
|
||||
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
|
||||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
||||
@@ -505,51 +677,6 @@ export function pruneStatsByDays(daysRaw) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redirect / URL helpers ─────────────────────────────────────────────────
|
||||
|
||||
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 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 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
|
||||
}
|
||||
|
||||
// ── Contact / email tracking ───────────────────────────────────────────────
|
||||
|
||||
export function noteContactEmailCooldown(emailAddress) {
|
||||
@@ -609,9 +736,21 @@ export function extractResendMessageId(result) {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function normalizeMessageType(value) {
|
||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
||||
return 'general'
|
||||
// One-off pass: lesson reminders are now on by default, so switch them on for accounts that
|
||||
// predate the default. Each account is marked as it is handled, so a later opt-out sticks.
|
||||
export async function backfillReminderDefault() {
|
||||
const now = new Date().toISOString()
|
||||
let updated = 0
|
||||
for (const user of state.studyUsers) {
|
||||
if (user.remindersDefaultedAt) continue
|
||||
if (user.studyRemindersEnabled !== true) {
|
||||
user.studyRemindersEnabled = true
|
||||
updated++
|
||||
}
|
||||
user.remindersDefaultedAt = now
|
||||
}
|
||||
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
|
||||
return updated
|
||||
}
|
||||
|
||||
// ── Study reminder scheduler ───────────────────────────────────────────────
|
||||
@@ -632,6 +771,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||
|
||||
const userSent = state.studyReminders.users[user.id] ?? {}
|
||||
for (const studySlug of enrolledStudySlugs) {
|
||||
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
|
||||
const study = Array.isArray(state.cachedSiteContent.studies)
|
||||
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||
: undefined
|
||||
@@ -707,7 +847,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
||||
|
||||
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
||||
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
|
||||
// Studies the student already finished aren't "unfinished business" for a come-back nudge.
|
||||
const enrolledStudies = enrolledStudySlugs
|
||||
.filter(slug => !isStudyUserCompleted(user, slug))
|
||||
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -765,14 +907,19 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
||||
const newlySentByStudy = {}
|
||||
|
||||
for (const studySlug of enrolledStudySlugs) {
|
||||
if (isStudyUserCompleted(user, studySlug)) continue
|
||||
const study = Array.isArray(state.cachedSiteContent.studies)
|
||||
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
||||
: undefined
|
||||
if (!study) continue
|
||||
|
||||
// Finished studies are self-paced: only mention lessons from the last week so a
|
||||
// late enrollee isn't sent the whole back catalogue in one digest.
|
||||
const isComplete = getEffectiveStudyStatus(study) === 'complete'
|
||||
for (const section of study.sections ?? []) {
|
||||
const releaseTime = getSectionReleaseTime(section)
|
||||
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
||||
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
if (sentForStudy.includes(section.id)) continue
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { lookup } from 'node:dns/promises'
|
||||
import net from 'node:net'
|
||||
|
||||
function isPrivateIPv4(ip) {
|
||||
const [a, b] = ip.split('.').map(Number)
|
||||
return (
|
||||
a === 0 || a === 10 || a === 127
|
||||
|| (a === 100 && b >= 64 && b <= 127) // CGNAT / Tailscale
|
||||
|| (a === 169 && b === 254) // link-local, cloud metadata
|
||||
|| (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168)
|
||||
|| a >= 224 // multicast / reserved
|
||||
)
|
||||
}
|
||||
|
||||
export function isPrivateAddress(ip) {
|
||||
if (net.isIPv4(ip)) return isPrivateIPv4(ip)
|
||||
if (net.isIPv6(ip)) {
|
||||
const lower = ip.toLowerCase()
|
||||
if (lower === '::' || lower === '::1') return true
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true // unique local
|
||||
if (/^fe[89ab]/.test(lower)) return true // link-local
|
||||
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)
|
||||
if (mapped) return isPrivateIPv4(mapped[1])
|
||||
return false
|
||||
}
|
||||
return true // not an IP at all: treat as unsafe
|
||||
}
|
||||
|
||||
// Throws unless `rawUrl` is an https URL whose host resolves only to public
|
||||
// addresses. Called when a webhook is registered and again before each delivery
|
||||
// (DNS can change between the two).
|
||||
export async function assertPublicHttpsUrl(rawUrl) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(rawUrl)
|
||||
} catch {
|
||||
throw new Error('Malformed URL.')
|
||||
}
|
||||
if (parsed.protocol !== 'https:') throw new Error('Only https:// URLs are allowed.')
|
||||
if (parsed.username || parsed.password) throw new Error('URLs with embedded credentials are not allowed.')
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '')
|
||||
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
|
||||
throw new Error('Internal hostnames are not allowed.')
|
||||
}
|
||||
const addresses = net.isIP(host) ? [{ address: host }] : await lookup(host, { all: true })
|
||||
if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address))) {
|
||||
throw new Error('URL resolves to a private or internal address.')
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [
|
||||
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
|
||||
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
|
||||
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
|
||||
{ id: 'TIT', name: 'Titus' },
|
||||
{ id: 'TIT', name: 'Titus', aliases: ['tit'] },
|
||||
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] },
|
||||
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
|
||||
{ id: 'JAM', name: 'James', aliases: ['jas'] },
|
||||
@@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) {
|
||||
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
|
||||
return index === -1 ? null : index + 1
|
||||
}
|
||||
|
||||
let referenceBookLookup = null
|
||||
|
||||
/**
|
||||
* Checks a passage reference the way the Scripture Index and passage fetch read it.
|
||||
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
|
||||
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
|
||||
* message explaining what to change.
|
||||
*/
|
||||
export function checkPassageReference(reference, bookHint) {
|
||||
if (!referenceBookLookup) {
|
||||
referenceBookLookup = new Set()
|
||||
for (const b of BIBLE_BOOKS) {
|
||||
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
|
||||
referenceBookLookup.add(key)
|
||||
referenceBookLookup.add(key.replace(/\s+/g, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
|
||||
if (!clean) return null
|
||||
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
|
||||
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
|
||||
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
|
||||
if (!book || !referenceBookLookup.has(book)) {
|
||||
return m[1]
|
||||
? `"${m[1]}" is not a recognized Bible book name.`
|
||||
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
|
||||
export interface NoteEntry {
|
||||
id: string
|
||||
anchor: NoteAnchorId
|
||||
html: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
export const NOTE_ANCHORS: NoteAnchorId[]
|
||||
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
|
||||
export function newNoteId(): string
|
||||
export function parseNoteEntries(raw: string): NoteEntry[]
|
||||
export function serializeNoteEntries(entries: NoteEntry[]): string
|
||||
export function noteHtmlToText(html: string): string
|
||||
export function noteEntriesToText(raw: string): string
|
||||
@@ -0,0 +1,79 @@
|
||||
// Lesson note storage format, shared by the client and the server.
|
||||
//
|
||||
// Each lesson's notes are stored as one string. Current format (v2):
|
||||
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
|
||||
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
|
||||
// plain text. Both are still read and are upgraded to v2 on the next save.
|
||||
|
||||
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
|
||||
|
||||
export const NOTE_ANCHOR_LABELS = {
|
||||
scripture: 'Scripture Text',
|
||||
commentary: 'Commentary',
|
||||
questions: 'Reflection Questions',
|
||||
general: 'General Notes',
|
||||
}
|
||||
|
||||
export function newNoteId() {
|
||||
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
export function parseNoteEntries(raw) {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
// Legacy plain-text note.
|
||||
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||
}
|
||||
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
|
||||
return parsed.entries
|
||||
.filter(e => e && typeof e.html === 'string' && e.html.trim())
|
||||
.map(e => ({
|
||||
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
|
||||
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
|
||||
html: e.html,
|
||||
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
|
||||
}))
|
||||
}
|
||||
return Object.entries(parsed)
|
||||
.filter(([, html]) => typeof html === 'string' && html.trim())
|
||||
.map(([anchor, html]) => ({
|
||||
id: `legacy-${anchor}`,
|
||||
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
|
||||
html,
|
||||
updatedAt: null,
|
||||
}))
|
||||
}
|
||||
|
||||
export function serializeNoteEntries(entries) {
|
||||
const kept = entries.filter(e => e.html && e.html.trim())
|
||||
if (kept.length === 0) return ''
|
||||
return JSON.stringify({ v: 2, entries: kept })
|
||||
}
|
||||
|
||||
export function noteHtmlToText(html) {
|
||||
return String(html)
|
||||
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// Plain-text rendering of a whole lesson's notes (used by the Word export).
|
||||
export function noteEntriesToText(raw) {
|
||||
return parseNoteEntries(raw)
|
||||
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
|
||||
.join('\n\n')
|
||||
}
|
||||
+2617
-630
File diff suppressed because it is too large.
Load diff
+3728
-2181
File diff suppressed because it is too large.
Load diff
+1631
-779
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,533 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
|
||||
export interface AdminAsset {
|
||||
filename: string
|
||||
key?: string
|
||||
storage?: 'r2' | 'local'
|
||||
url: string
|
||||
folder?: string
|
||||
sizeBytes: number
|
||||
updatedAt: string | null
|
||||
tags?: string[]
|
||||
usedIn?: number
|
||||
}
|
||||
|
||||
interface AssetManagerProps {
|
||||
assets: AdminAsset[]
|
||||
onReload: () => Promise<void>
|
||||
onMessage: (message: string) => void
|
||||
message: string
|
||||
}
|
||||
|
||||
type SortKey = 'newest' | 'oldest' | 'name' | 'size' | 'used'
|
||||
type TypeFilter = 'all' | 'image' | 'pdf' | 'doc'
|
||||
type ViewMode = 'list' | 'grid'
|
||||
|
||||
const MOVE_FOLDERS = ['uploads', 'images', 'guides', 'finished']
|
||||
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
const assetId = (a: AdminAsset) => a.key ?? `${a.folder ?? ''}/${a.filename}`
|
||||
const isImage = (name: string) => /\.(png|jpe?g|webp|gif)$/i.test(name)
|
||||
const isPdf = (name: string) => /\.pdf$/i.test(name)
|
||||
const fileKind = (name: string): TypeFilter => (isImage(name) ? 'image' : isPdf(name) ? 'pdf' : 'doc')
|
||||
const extOf = (name: string) => (name.match(/\.[a-z0-9]+$/i)?.[0] ?? '').toLowerCase()
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${Math.max(bytes / 1024, 0.1).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function formatWhen(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function locationLabel(a: AdminAsset) {
|
||||
if (a.storage === 'local' || a.folder === 'local') return 'Local disk'
|
||||
return a.folder ? `R2 / ${a.folder}` : 'R2'
|
||||
}
|
||||
|
||||
async function readError(res: Response, fallback: string) {
|
||||
const data = await res.json().catch(() => ({})) as { message?: string }
|
||||
return data.message ?? fallback
|
||||
}
|
||||
|
||||
export default function AssetManager({ assets, onReload, onMessage, message }: AssetManagerProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [folderFilter, setFolderFilter] = useState('all')
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null)
|
||||
const [unusedOnly, setUnusedOnly] = useState(false)
|
||||
const [sort, setSort] = useState<SortKey>('newest')
|
||||
const [view, setView] = useState<ViewMode>('list')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [uploadDest, setUploadDest] = useState('r2:uploads')
|
||||
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null)
|
||||
const [tagDrafts, setTagDrafts] = useState<Record<string, string>>({})
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [bulkMove, setBulkMove] = useState('')
|
||||
|
||||
const folders = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const a of assets) {
|
||||
const key = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||
}, [assets])
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const a of assets) for (const t of a.tags ?? []) counts.set(t, (counts.get(t) ?? 0) + 1)
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
}, [assets])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const list = assets.filter(a => {
|
||||
const folderKey = a.storage === 'local' || a.folder === 'local' ? 'local' : (a.folder || 'r2')
|
||||
if (folderFilter !== 'all' && folderKey !== folderFilter) return false
|
||||
if (typeFilter !== 'all' && fileKind(a.filename) !== typeFilter) return false
|
||||
if (tagFilter && !(a.tags ?? []).includes(tagFilter)) return false
|
||||
if (unusedOnly && (a.usedIn ?? 0) > 0) return false
|
||||
if (q) {
|
||||
const haystack = `${a.filename} ${a.folder ?? ''} ${(a.tags ?? []).join(' ')}`.toLowerCase()
|
||||
if (!q.split(/\s+/).every(word => haystack.includes(word))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const dateOf = (a: AdminAsset) => a.updatedAt ?? ''
|
||||
list.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'oldest': return dateOf(a).localeCompare(dateOf(b))
|
||||
case 'name': return a.filename.localeCompare(b.filename, undefined, { numeric: true })
|
||||
case 'size': return b.sizeBytes - a.sizeBytes
|
||||
case 'used': return (b.usedIn ?? 0) - (a.usedIn ?? 0)
|
||||
default: return dateOf(b).localeCompare(dateOf(a))
|
||||
}
|
||||
})
|
||||
return list
|
||||
}, [assets, search, folderFilter, typeFilter, tagFilter, unusedOnly, sort])
|
||||
|
||||
const visibleIds = visible.map(assetId)
|
||||
const selectedVisible = visibleIds.filter(id => selected.has(id))
|
||||
const allVisibleSelected = visible.length > 0 && selectedVisible.length === visible.length
|
||||
const totalBytes = assets.reduce((sum, a) => sum + a.sizeBytes, 0)
|
||||
const filtersActive = Boolean(search || folderFilter !== 'all' || typeFilter !== 'all' || tagFilter || unusedOnly)
|
||||
const selectedAssets = assets.filter(a => selected.has(assetId(a)))
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('')
|
||||
setFolderFilter('all')
|
||||
setTypeFilter('all')
|
||||
setTagFilter(null)
|
||||
setUnusedOnly(false)
|
||||
}
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllVisible() {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (allVisibleSelected) visibleIds.forEach(id => next.delete(id))
|
||||
else visibleIds.forEach(id => next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
event.target.value = ''
|
||||
if (files.length === 0) return
|
||||
setBusy(true)
|
||||
onMessage('')
|
||||
const [storage, folder] = uploadDest === 'local' ? ['local', 'uploads'] : ['r2', uploadDest.replace('r2:', '')]
|
||||
let ok = 0
|
||||
const failures: string[] = []
|
||||
for (const file of files) {
|
||||
try {
|
||||
if (file.size > MAX_UPLOAD_BYTES) throw new Error('over the 8 MB limit')
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(new Error('could not read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
const res = await fetch('/api/admin-assets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, dataUrl, storage, folder }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await readError(res, 'upload failed'))
|
||||
ok += 1
|
||||
} catch (err) {
|
||||
failures.push(`${file.name} (${err instanceof Error ? err.message : 'failed'})`)
|
||||
}
|
||||
}
|
||||
await onReload().catch(() => {})
|
||||
setBusy(false)
|
||||
onMessage(failures.length
|
||||
? `Uploaded ${ok} of ${files.length}. Failed: ${failures.join('; ')}`
|
||||
: `Uploaded ${ok} file${ok === 1 ? '' : 's'}.`)
|
||||
}
|
||||
|
||||
async function saveTags(asset: AdminAsset, tags: string[]) {
|
||||
try {
|
||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tags }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await readError(res, 'Save failed'))
|
||||
await onReload()
|
||||
} catch (err) {
|
||||
onMessage(err instanceof Error ? err.message : 'Save failed.')
|
||||
}
|
||||
}
|
||||
|
||||
function addTags(asset: AdminAsset) {
|
||||
const id = assetId(asset)
|
||||
const incoming = (tagDrafts[id] ?? '').split(',').map(t => t.trim()).filter(Boolean)
|
||||
setTagDrafts(d => ({ ...d, [id]: '' }))
|
||||
if (incoming.length === 0) return
|
||||
const merged = [...new Set([...(asset.tags ?? []), ...incoming])]
|
||||
void saveTags(asset, merged)
|
||||
}
|
||||
|
||||
function warnIfUsed(asset: AdminAsset, action: string) {
|
||||
const used = asset.usedIn ?? 0
|
||||
if (used === 0) return true
|
||||
return confirm(`${asset.filename} is used ${used} time${used === 1 ? '' : 's'} in your site content. ${action} changes its URL, so those places will show a broken file until you re-select it. Continue?`)
|
||||
}
|
||||
|
||||
async function patchAsset(asset: AdminAsset, body: Record<string, unknown>) {
|
||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error(await readError(res, 'Update failed'))
|
||||
}
|
||||
|
||||
async function commitRename(asset: AdminAsset) {
|
||||
const value = renaming?.value.trim() ?? ''
|
||||
setRenaming(null)
|
||||
if (!value || value === asset.filename || `${value}${extOf(asset.filename)}` === asset.filename) return
|
||||
if (!warnIfUsed(asset, 'Renaming')) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await patchAsset(asset, { newName: value })
|
||||
await onReload()
|
||||
onMessage(`Renamed ${asset.filename}.`)
|
||||
} catch (err) {
|
||||
onMessage(err instanceof Error ? err.message : 'Rename failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function moveAssets(targets: AdminAsset[], folder: string) {
|
||||
const movable = targets.filter(a => a.storage !== 'local' && a.folder !== 'local' && a.folder !== folder)
|
||||
if (movable.length === 0) {
|
||||
onMessage('Nothing to move — local files can’t be moved, and these are already in that folder.')
|
||||
return
|
||||
}
|
||||
const usedCount = movable.filter(a => (a.usedIn ?? 0) > 0).length
|
||||
if (usedCount > 0 && !confirm(`${usedCount} of the ${movable.length} file${movable.length === 1 ? '' : 's'} you’re moving are used in site content. Moving changes their URLs, so those places will show a broken file until re-selected. Continue?`)) return
|
||||
setBusy(true)
|
||||
let ok = 0
|
||||
const failures: string[] = []
|
||||
for (const asset of movable) {
|
||||
try {
|
||||
await patchAsset(asset, { folder })
|
||||
ok += 1
|
||||
} catch (err) {
|
||||
failures.push(`${asset.filename} (${err instanceof Error ? err.message : 'failed'})`)
|
||||
}
|
||||
}
|
||||
await onReload().catch(() => {})
|
||||
setBusy(false)
|
||||
setSelected(new Set())
|
||||
setBulkMove('')
|
||||
onMessage(failures.length ? `Moved ${ok}. Failed: ${failures.join('; ')}` : `Moved ${ok} file${ok === 1 ? '' : 's'} to ${folder}/.`)
|
||||
}
|
||||
|
||||
async function deleteAssets(targets: AdminAsset[]) {
|
||||
if (targets.length === 0) return
|
||||
const used = targets.filter(a => (a.usedIn ?? 0) > 0).length
|
||||
const label = targets.length === 1 ? targets[0].filename : `${targets.length} files`
|
||||
const warning = used > 0 ? `\n\n${used} of these ${used === 1 ? 'is' : 'are'} used in your site content and will show as broken.` : ''
|
||||
if (!confirm(`Permanently delete ${label}?${warning}`)) return
|
||||
setBusy(true)
|
||||
let ok = 0
|
||||
for (const asset of targets) {
|
||||
try {
|
||||
const qs = asset.folder ? `?folder=${encodeURIComponent(asset.folder)}` : ''
|
||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(asset.filename)}${qs}`, { method: 'DELETE' })
|
||||
if (res.ok) ok += 1
|
||||
} catch { /* counted as failed below */ }
|
||||
}
|
||||
await onReload().catch(() => {})
|
||||
setBusy(false)
|
||||
setSelected(new Set())
|
||||
onMessage(ok === targets.length ? `Deleted ${ok} file${ok === 1 ? '' : 's'}.` : `Deleted ${ok} of ${targets.length}.`)
|
||||
}
|
||||
|
||||
function copyUrl(asset: AdminAsset) {
|
||||
const absolute = asset.url.startsWith('http') ? asset.url : `${window.location.origin}${asset.url}`
|
||||
navigator.clipboard.writeText(absolute).then(() => {
|
||||
setCopiedId(assetId(asset))
|
||||
setTimeout(() => setCopiedId(id => (id === assetId(asset) ? null : id)), 1500)
|
||||
}).catch(() => onMessage('Could not copy to clipboard.'))
|
||||
}
|
||||
|
||||
function renderThumb(asset: AdminAsset) {
|
||||
if (isImage(asset.filename)) {
|
||||
return <img src={asset.url} alt="" loading="lazy" className="am-thumb-img" />
|
||||
}
|
||||
return <span className="am-thumb-file">{extOf(asset.filename).replace('.', '').toUpperCase() || 'FILE'}</span>
|
||||
}
|
||||
|
||||
function renderTags(asset: AdminAsset) {
|
||||
const id = assetId(asset)
|
||||
return (
|
||||
<div className="am-tags">
|
||||
{(asset.tags ?? []).map(tag => (
|
||||
<span key={tag} className="am-tag">
|
||||
<button type="button" className="am-tag-label" onClick={() => setTagFilter(tag)} title={`Filter by "${tag}"`}>{tag}</button>
|
||||
<button type="button" className="am-tag-x" aria-label={`Remove tag ${tag}`} onClick={() => void saveTags(asset, (asset.tags ?? []).filter(t => t !== tag))}>×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
className="am-tag-input"
|
||||
placeholder="+ tag"
|
||||
value={tagDrafts[id] ?? ''}
|
||||
onChange={e => setTagDrafts(d => ({ ...d, [id]: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTags(asset) } }}
|
||||
onBlur={() => addTags(asset)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderActions(asset: AdminAsset) {
|
||||
const id = assetId(asset)
|
||||
const canMove = asset.storage !== 'local' && asset.folder !== 'local'
|
||||
return (
|
||||
<div className="am-actions">
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => copyUrl(asset)}>
|
||||
{copiedId === id ? 'Copied ✓' : 'Copy URL'}
|
||||
</button>
|
||||
<a className="btn-admin-reset btn-admin-reset--compact" href={asset.url} target="_blank" rel="noreferrer">Open</a>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => setRenaming({ id, value: asset.filename.replace(/\.[a-z0-9]+$/i, '') })}>Rename</button>
|
||||
{canMove && (
|
||||
<select
|
||||
className="am-select am-select--compact"
|
||||
value=""
|
||||
disabled={busy}
|
||||
aria-label={`Move ${asset.filename}`}
|
||||
onChange={e => {
|
||||
const folder = e.target.value
|
||||
if (folder && warnIfUsed(asset, 'Moving')) void moveAssets([asset], folder)
|
||||
}}
|
||||
>
|
||||
<option value="">Move to…</option>
|
||||
{MOVE_FOLDERS.filter(f => f !== asset.folder).map(f => <option key={f} value={f}>{f}/</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets([asset])}>Delete</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderName(asset: AdminAsset) {
|
||||
const id = assetId(asset)
|
||||
if (renaming?.id === id) {
|
||||
return (
|
||||
<div className="am-rename">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="am-rename-input"
|
||||
value={renaming.value}
|
||||
onChange={e => setRenaming({ id, value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') void commitRename(asset)
|
||||
if (e.key === 'Escape') setRenaming(null)
|
||||
}}
|
||||
/>
|
||||
<span className="am-rename-ext">{extOf(asset.filename)}</span>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => void commitRename(asset)}>Save</button>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setRenaming(null)}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <a className="am-name" href={asset.url} target="_blank" rel="noreferrer" title={asset.filename}>{asset.filename}</a>
|
||||
}
|
||||
|
||||
function renderMeta(asset: AdminAsset) {
|
||||
const used = asset.usedIn ?? 0
|
||||
return (
|
||||
<div className="am-meta">
|
||||
<span className="am-badge">{locationLabel(asset)}</span>
|
||||
<span>{formatSize(asset.sizeBytes)}</span>
|
||||
<span>{formatWhen(asset.updatedAt)}</span>
|
||||
<span className={used > 0 ? 'am-used' : 'am-unused'}>{used > 0 ? `Used ${used}×` : 'Unused'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section am-root" aria-label="Asset manager">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Asset Manager</h2>
|
||||
<p>Upload, find, tag, rename, and organize the images and files hosted on this site.</p>
|
||||
</div>
|
||||
|
||||
<div className="am-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="am-search"
|
||||
placeholder="Search by name, folder, or tag…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
aria-label="Search assets"
|
||||
/>
|
||||
<select className="am-select" value={folderFilter} onChange={e => setFolderFilter(e.target.value)} aria-label="Filter by folder">
|
||||
<option value="all">All folders</option>
|
||||
{folders.map(([name, count]) => <option key={name} value={name}>{name === 'local' ? 'Local disk' : `${name}/`} ({count})</option>)}
|
||||
</select>
|
||||
<select className="am-select" value={typeFilter} onChange={e => setTypeFilter(e.target.value as TypeFilter)} aria-label="Filter by type">
|
||||
<option value="all">All types</option>
|
||||
<option value="image">Images</option>
|
||||
<option value="pdf">PDFs</option>
|
||||
<option value="doc">Word docs</option>
|
||||
</select>
|
||||
<select className="am-select" value={sort} onChange={e => setSort(e.target.value as SortKey)} aria-label="Sort assets">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="size">Largest first</option>
|
||||
<option value="used">Most used</option>
|
||||
</select>
|
||||
<div className="am-view-toggle" role="group" aria-label="View">
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>List</button>
|
||||
<button type="button" className={view === 'grid' ? 'is-active' : ''} onClick={() => setView('grid')}>Grid</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="am-upload-bar">
|
||||
<select className="am-select" value={uploadDest} onChange={e => setUploadDest(e.target.value)} disabled={busy} aria-label="Upload destination">
|
||||
<option value="r2:uploads">Upload to R2 – uploads/</option>
|
||||
<option value="r2:images">Upload to R2 – images/</option>
|
||||
<option value="r2:guides">Upload to R2 – guides/</option>
|
||||
<option value="r2:finished">Upload to R2 – finished/</option>
|
||||
<option value="local">Upload to local server disk</option>
|
||||
</select>
|
||||
<label className="btn-admin-reset" style={{ cursor: busy ? 'wait' : 'pointer' }}>
|
||||
{busy ? 'Working…' : 'Upload files'}
|
||||
<input type="file" multiple accept="image/*,.pdf,.doc,.docx" style={{ display: 'none' }} onChange={handleUpload} disabled={busy} />
|
||||
</label>
|
||||
<label className="am-check-inline">
|
||||
<input type="checkbox" checked={unusedOnly} onChange={e => setUnusedOnly(e.target.checked)} /> Unused only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="am-tagbar" aria-label="Filter by tag">
|
||||
<span className="am-tagbar-label">Tags:</span>
|
||||
{allTags.map(([tag, count]) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`am-chip${tagFilter === tag ? ' is-active' : ''}`}
|
||||
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
|
||||
>
|
||||
{tag} <span className="am-chip-count">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="am-summary">
|
||||
<label className="am-check-inline">
|
||||
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} disabled={visible.length === 0} /> Select all shown
|
||||
</label>
|
||||
<span>
|
||||
Showing {visible.length} of {assets.length} · {formatSize(totalBytes)} total
|
||||
</span>
|
||||
{filtersActive && <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button>}
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="am-bulkbar" role="region" aria-label="Bulk actions">
|
||||
<strong>{selected.size} selected</strong>
|
||||
<select className="am-select am-select--compact" value={bulkMove} disabled={busy} onChange={e => { const f = e.target.value; setBulkMove(f); if (f) void moveAssets(selectedAssets, f) }} aria-label="Move selected to folder">
|
||||
<option value="">Move selected to…</option>
|
||||
{MOVE_FOLDERS.map(f => <option key={f} value={f}>{f}/</option>)}
|
||||
</select>
|
||||
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void deleteAssets(selectedAssets)}>Delete selected</button>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setSelected(new Set())}>Clear selection</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
|
||||
{assets.length > 0 && visible.length === 0 && (
|
||||
<p className="admin-stats-note">No assets match these filters. <button type="button" className="am-link-btn" onClick={clearFilters}>Clear filters</button></p>
|
||||
)}
|
||||
|
||||
{visible.length > 0 && view === 'list' && (
|
||||
<ul className="am-list">
|
||||
{visible.map(asset => {
|
||||
const id = assetId(asset)
|
||||
return (
|
||||
<li key={id} className={`am-row${selected.has(id) ? ' is-selected' : ''}`}>
|
||||
<input type="checkbox" className="am-row-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
|
||||
<a className="am-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
|
||||
<div className="am-main">
|
||||
{renderName(asset)}
|
||||
{renderMeta(asset)}
|
||||
</div>
|
||||
<div className="am-tags-cell">{renderTags(asset)}</div>
|
||||
<div className="am-actions-cell">{renderActions(asset)}</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{visible.length > 0 && view === 'grid' && (
|
||||
<ul className="am-grid">
|
||||
{visible.map(asset => {
|
||||
const id = assetId(asset)
|
||||
return (
|
||||
<li key={id} className={`am-card${selected.has(id) ? ' is-selected' : ''}`}>
|
||||
<input type="checkbox" className="am-card-check" checked={selected.has(id)} onChange={() => toggleSelected(id)} aria-label={`Select ${asset.filename}`} />
|
||||
<a className="am-card-thumb" href={asset.url} target="_blank" rel="noreferrer" tabIndex={-1}>{renderThumb(asset)}</a>
|
||||
<div className="am-card-body">
|
||||
{renderName(asset)}
|
||||
{renderMeta(asset)}
|
||||
{renderTags(asset)}
|
||||
{renderActions(asset)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{message && <p className="admin-stats-note" role="status">{message}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
export interface InboxSubmission {
|
||||
id: string
|
||||
submittedAt: string
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: string
|
||||
subscribe: boolean
|
||||
archived?: boolean
|
||||
read?: boolean
|
||||
source?: 'contact-form' | 'download' | 'inbound-email' | 'resend-import'
|
||||
inboundTo?: string
|
||||
htmlBody?: string | null
|
||||
}
|
||||
|
||||
interface InboxPanelProps {
|
||||
submissions: InboxSubmission[]
|
||||
setSubmissions: React.Dispatch<React.SetStateAction<InboxSubmission[]>>
|
||||
}
|
||||
|
||||
type Tab = 'inbox' | 'unread' | 'archived' | 'all'
|
||||
type Kind = 'email' | 'newsletter' | 'download' | 'message'
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
question: 'Bible Question',
|
||||
community: 'Community Question',
|
||||
testimony: 'Testimony',
|
||||
topic: 'Topic Request',
|
||||
gospel: 'Gospel Courtroom Question',
|
||||
general: 'General Message',
|
||||
}
|
||||
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
|
||||
|
||||
const CP1252: Record<string, number> = {
|
||||
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
|
||||
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
|
||||
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
|
||||
}
|
||||
|
||||
// Earlier inbound emails were stored with UTF-8 text decoded as Latin-1 ("’", "é"); undo that for display.
|
||||
function fixMojibake(text: string) {
|
||||
if (!/[ÃÂâð][\u0080-ÿ€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
|
||||
const bytes: number[] = []
|
||||
for (const ch of text) {
|
||||
const code = ch.codePointAt(0) ?? 0
|
||||
if (code <= 0xff) bytes.push(code)
|
||||
else if (CP1252[ch] !== undefined) bytes.push(CP1252[ch])
|
||||
else return text
|
||||
}
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes))
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function kindOf(s: InboxSubmission): Kind {
|
||||
if (s.source === 'inbound-email') return 'email'
|
||||
if (/^newsletter signup/i.test(s.message)) return 'newsletter'
|
||||
if (s.source === 'download' || /^requested\b/i.test(s.message)) return 'download'
|
||||
return 'message'
|
||||
}
|
||||
|
||||
function parse(s: InboxSubmission) {
|
||||
const text = fixMojibake(s.message ?? '')
|
||||
const m = /^Subject:\s*(.*?)(?:\r?\n\s*\r?\n|\r?\n)([\s\S]*)$/.exec(text)
|
||||
if (m) return { subject: m[1].trim() || '(no subject)', body: m[2].trim() }
|
||||
const single = /^Subject:\s*(.*)$/.exec(text)
|
||||
if (single) return { subject: single[1].trim() || '(no subject)', body: '' }
|
||||
const [first, ...rest] = text.split(/\r?\n/)
|
||||
return { subject: first.trim() || '(no subject)', body: rest.join('\n').trim() }
|
||||
}
|
||||
|
||||
function fmt(value: string) {
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
export default function InboxPanel({ submissions: allSubmissions, setSubmissions }: InboxPanelProps) {
|
||||
// Subscribers imported from Resend are bookkeeping records, not messages.
|
||||
const submissions = useMemo(() => allSubmissions.filter(s => s.source !== 'resend-import'), [allSubmissions])
|
||||
const [tab, setTab] = useState<Tab>('inbox')
|
||||
const [kind, setKind] = useState<'all' | Kind>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState<'newest' | 'oldest' | 'sender'>('newest')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [formatted, setFormatted] = useState(false)
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
const rows = useMemo(() => submissions.map(s => ({ s, kind: kindOf(s), ...parse(s) })), [submissions])
|
||||
const unread = submissions.filter(s => !s.archived && !s.read).length
|
||||
const archivedCount = submissions.filter(s => s.archived).length
|
||||
|
||||
const kindCounts = useMemo(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const r of rows) c[r.kind] = (c[r.kind] ?? 0) + 1
|
||||
return c
|
||||
}, [rows])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
const list = rows.filter(r => {
|
||||
const { s } = r
|
||||
if (tab === 'inbox' && s.archived) return false
|
||||
if (tab === 'unread' && (s.archived || s.read)) return false
|
||||
if (tab === 'archived' && !s.archived) return false
|
||||
if (kind !== 'all' && r.kind !== kind) return false
|
||||
if (q.length) {
|
||||
const hay = `${s.name} ${s.email} ${r.subject} ${r.body}`.toLowerCase()
|
||||
if (!q.every(w => hay.includes(w))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
list.sort((a, b) => {
|
||||
if (sort === 'sender') return a.s.name.localeCompare(b.s.name)
|
||||
const d = new Date(a.s.submittedAt).getTime() - new Date(b.s.submittedAt).getTime()
|
||||
return sort === 'oldest' ? d : -d
|
||||
})
|
||||
return list
|
||||
}, [rows, tab, kind, search, sort])
|
||||
|
||||
const open = openId ? rows.find(r => r.s.id === openId) ?? null : null
|
||||
const visibleIds = visible.map(r => r.s.id)
|
||||
const allChecked = visible.length > 0 && visibleIds.every(id => checked.has(id))
|
||||
|
||||
async function bulk(ids: string[], action: 'archive' | 'unarchive' | 'read' | 'unread' | 'delete') {
|
||||
if (ids.length === 0) return
|
||||
if (action === 'delete' && !confirm(`Permanently delete ${ids.length} message${ids.length === 1 ? '' : 's'}? This can’t be undone.`)) return
|
||||
setBusy(true)
|
||||
setNote('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-contact-submissions/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, action }),
|
||||
})
|
||||
if (!res.ok) throw new Error('failed')
|
||||
const idSet = new Set(ids)
|
||||
setSubmissions(prev => action === 'delete'
|
||||
? prev.filter(s => !idSet.has(s.id))
|
||||
: prev.map(s => !idSet.has(s.id) ? s : action === 'archive' ? { ...s, archived: true } : action === 'unarchive' ? { ...s, archived: false } : { ...s, read: action === 'read' }))
|
||||
setChecked(prev => { const n = new Set(prev); ids.forEach(id => n.delete(id)); return n })
|
||||
if (openId && idSet.has(openId) && (action === 'delete' || action === 'archive')) setOpenId(null)
|
||||
} catch {
|
||||
setNote('That action failed. Please try again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openMessage(id: string) {
|
||||
setFormatted(false)
|
||||
if (openId === id) { setOpenId(null); return }
|
||||
setOpenId(id)
|
||||
const sub = submissions.find(s => s.id === id)
|
||||
if (sub && !sub.read) void bulk([id], 'read')
|
||||
}
|
||||
|
||||
const tabs: Array<[Tab, string, number | null]> = [
|
||||
['inbox', 'Inbox', submissions.length - archivedCount],
|
||||
['unread', 'Unread', unread],
|
||||
['archived', 'Archived', archivedCount],
|
||||
['all', 'All', submissions.length],
|
||||
]
|
||||
const selectedIds = [...checked].filter(id => submissions.some(s => s.id === id))
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section ib-root" aria-label="Inbox">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Inbox</h2>
|
||||
<p>{unread} unread · {archivedCount} archived · {submissions.length} total</p>
|
||||
</div>
|
||||
|
||||
<div className="ib-toolbar">
|
||||
<input type="search" className="ib-search" placeholder="Search sender, subject, or message…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search inbox" />
|
||||
<select className="ib-select" value={kind} onChange={e => setKind(e.target.value as 'all' | Kind)} aria-label="Filter by type">
|
||||
<option value="all">All types</option>
|
||||
{(Object.keys(KIND_LABEL) as Kind[]).map(k => <option key={k} value={k}>{KIND_LABEL[k]} ({kindCounts[k] ?? 0})</option>)}
|
||||
</select>
|
||||
<select className="ib-select" value={sort} onChange={e => setSort(e.target.value as typeof sort)} aria-label="Sort">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="sender">Sender A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ib-tabs" role="tablist">
|
||||
{tabs.map(([id, label, count]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} className={`ib-tab${tab === id ? ' is-active' : ''}`} onClick={() => setTab(id)}>
|
||||
{label}{count !== null && <span className="ib-tab-count">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ib-bar">
|
||||
<label className="ib-check">
|
||||
<input type="checkbox" checked={allChecked} disabled={visible.length === 0} onChange={() => setChecked(prev => { const n = new Set(prev); visibleIds.forEach(id => allChecked ? n.delete(id) : n.add(id)); return n })} />
|
||||
Select all shown
|
||||
</label>
|
||||
<span className="ib-count">{visible.length} shown</span>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="ib-bulk">
|
||||
<strong>{selectedIds.length} selected</strong>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'read')}>Mark read</button>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unread')}>Mark unread</button>
|
||||
{tab === 'archived'
|
||||
? <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'unarchive')}>Unarchive</button>
|
||||
: <button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk(selectedIds, 'archive')}>Archive</button>}
|
||||
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk(selectedIds, 'delete')}>Delete</button>
|
||||
</div>
|
||||
)}
|
||||
{unread > 0 && tab !== 'archived' && selectedIds.length === 0 && (
|
||||
<button type="button" className="am-link-btn" disabled={busy} onClick={() => void bulk(submissions.filter(s => !s.archived && !s.read).map(s => s.id), 'read')}>Mark all read</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{note && <p className="admin-stats-note" role="status">{note}</p>}
|
||||
{submissions.length === 0 && <p className="admin-stats-note">No messages yet.</p>}
|
||||
{submissions.length > 0 && visible.length === 0 && <p className="admin-stats-note">No messages match these filters.</p>}
|
||||
|
||||
<div className={`ib-layout${open ? ' has-open' : ''}`}>
|
||||
<ul className="ib-list">
|
||||
{visible.map(r => {
|
||||
const { s } = r
|
||||
const isUnread = !s.read && !s.archived
|
||||
return (
|
||||
<li key={s.id} className={`ib-item${isUnread ? ' is-unread' : ''}${openId === s.id ? ' is-open' : ''}`}>
|
||||
<input type="checkbox" checked={checked.has(s.id)} onChange={() => setChecked(prev => { const n = new Set(prev); if (n.has(s.id)) n.delete(s.id); else n.add(s.id); return n })} aria-label={`Select message from ${s.name}`} />
|
||||
<button type="button" className="ib-item-main" onClick={() => openMessage(s.id)}>
|
||||
<span className="ib-item-top">
|
||||
<span className="ib-from">{isUnread && <i className="ib-dot" aria-label="Unread" />}{fixMojibake(s.name)}</span>
|
||||
<span className="ib-date">{fmt(s.submittedAt)}</span>
|
||||
</span>
|
||||
<span className="ib-subject">{r.subject}</span>
|
||||
{r.body && <span className="ib-preview">{r.body.replace(/\s+/g, ' ').slice(0, 220)}</span>}
|
||||
<span className="ib-tags">
|
||||
<span className={`ib-badge ib-badge--${r.kind}`}>{r.kind === 'message' ? (TYPE_LABEL[s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[r.kind]}</span>
|
||||
{s.archived && <span className="ib-badge">Archived</span>}
|
||||
<span className="ib-addr">{s.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{open && (
|
||||
<article className="ib-detail">
|
||||
<header className="ib-detail-head">
|
||||
<div className="ib-detail-title">
|
||||
<h3>{open.subject}</h3>
|
||||
<p><strong>{fixMojibake(open.s.name)}</strong> <<a href={`mailto:${open.s.email}`}>{open.s.email}</a>></p>
|
||||
<p className="ib-detail-sub">
|
||||
{fmt(open.s.submittedAt)} · {open.kind === 'message' ? (TYPE_LABEL[open.s.messageType] ?? KIND_LABEL.message) : KIND_LABEL[open.kind]}
|
||||
{open.s.inboundTo ? ` · to ${open.s.inboundTo}` : ''}
|
||||
{open.s.subscribe ? ' · subscribed' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setOpenId(null)} aria-label="Close message">✕</button>
|
||||
</header>
|
||||
<div className="ib-detail-actions">
|
||||
<a className="btn-admin-save" href={`mailto:${open.s.email}?subject=${encodeURIComponent(/^re:/i.test(open.subject) ? open.subject : `Re: ${open.subject}`)}`}>Reply</a>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], open.s.archived ? 'unarchive' : 'archive')}>{open.s.archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={busy} onClick={() => void bulk([open.s.id], 'unread')}>Mark unread</button>
|
||||
<button type="button" className="btn-admin-remove" disabled={busy} onClick={() => void bulk([open.s.id], 'delete')}>Delete</button>
|
||||
{open.s.htmlBody && (
|
||||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setFormatted(f => !f)}>{formatted ? 'Show plain text' : 'Show formatted'}</button>
|
||||
)}
|
||||
</div>
|
||||
{formatted && open.s.htmlBody
|
||||
? <iframe className="ib-frame" title="Formatted email" sandbox="" srcDoc={open.s.htmlBody} />
|
||||
: <div className="ib-body">{open.body || open.subject}</div>}
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+45
-18
@@ -2,11 +2,41 @@ import { useEffect, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||||
const UTM_PENDING_KEY = 'vbn_pending_utm'
|
||||
|
||||
function hasConsent(): boolean {
|
||||
return localStorage.getItem(CONSENT_KEY) === 'accepted'
|
||||
}
|
||||
|
||||
// Capture UTM params immediately on module load, before consent is given.
|
||||
// They're held in sessionStorage so they survive a consent-banner accept.
|
||||
;(function captureUtmEarly() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const utm: Record<string, string> = {}
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
||||
const val = params.get(key)
|
||||
if (val) utm[key] = val
|
||||
}
|
||||
if (Object.keys(utm).length > 0) {
|
||||
sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm }))
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
})()
|
||||
|
||||
// The client gates on a localStorage flag; the server gates on a cookie.
|
||||
// If the cookie is lost (cleared separately, dropped by the browser on a
|
||||
// plain-HTTP deployment, etc.) while localStorage still says "accepted",
|
||||
// the client would otherwise keep believing tracking is on and silently
|
||||
// lose every event forever. Self-heal by clearing the stale flag so the
|
||||
// consent banner reappears instead of leaking data indefinitely.
|
||||
export async function checkAnalyticsConsentResponse(res: Response): Promise<void> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.reason === 'no-consent') localStorage.removeItem(CONSENT_KEY)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function sendEvent(type: string, data: Record<string, unknown>): void {
|
||||
if (!hasConsent()) return
|
||||
fetch('/api/analytics/event', {
|
||||
@@ -14,7 +44,7 @@ export function sendEvent(type: string, data: Record<string, unknown>): void {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, ...data }),
|
||||
keepalive: true,
|
||||
}).catch(() => {})
|
||||
}).then(checkAnalyticsConsentResponse).catch(() => {})
|
||||
}
|
||||
|
||||
// --- Scroll depth ---
|
||||
@@ -45,7 +75,7 @@ export function useScrollDepthTracking() {
|
||||
// --- Time on page ---
|
||||
export function useTimeOnPage() {
|
||||
const location = useLocation()
|
||||
const enteredAt = useRef(Date.now())
|
||||
const enteredAt = useRef(0)
|
||||
const path = useRef(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,22 +99,21 @@ export function useTimeOnPage() {
|
||||
}, [location.pathname])
|
||||
}
|
||||
|
||||
// --- UTM capture (runs once per page load) ---
|
||||
// --- UTM capture ---
|
||||
// UTM params are captured into sessionStorage before consent (see top of file).
|
||||
// This hook drains sessionStorage and sends the event once consent is present.
|
||||
// Running without a dep array so it retries after the consent banner triggers a re-render.
|
||||
export function useUTMCapture() {
|
||||
const captured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (captured.current || !hasConsent()) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const utm: Record<string, string> = {}
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
||||
const val = params.get(key)
|
||||
if (val) utm[key] = val
|
||||
}
|
||||
if (Object.keys(utm).length === 0) return
|
||||
captured.current = true
|
||||
sendEvent('utm', { path: window.location.pathname, ...utm })
|
||||
}, [])
|
||||
if (!hasConsent()) return
|
||||
try {
|
||||
const raw = sessionStorage.getItem(UTM_PENDING_KEY)
|
||||
if (!raw) return
|
||||
const utm = JSON.parse(raw) as Record<string, string>
|
||||
sessionStorage.removeItem(UTM_PENDING_KEY)
|
||||
sendEvent('utm', utm)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
}
|
||||
|
||||
// --- All link clicks (internal + external) ---
|
||||
@@ -125,5 +154,3 @@ export function useLinkClickTracking() {
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** @deprecated Use useLinkClickTracking instead */
|
||||
export function useOutboundLinkTracking() { useLinkClickTracking() }
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Trend { thisWeek: number; lastWeek: number; series: number[] }
|
||||
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
||||
|
||||
export interface DashboardData {
|
||||
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
|
||||
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
||||
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
||||
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
||||
studyProgress: {
|
||||
studies: { slug: string; title: string; enrolled: number; lessonsCompleted: number; certificates: number }[]
|
||||
certificatesTotal: number
|
||||
lastActivityAt: string | null
|
||||
}
|
||||
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
||||
nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
||||
health: {
|
||||
resendConfigured: boolean
|
||||
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||||
backupCount: number | null
|
||||
dataDirWritable: boolean | null
|
||||
writes: { hitStats: { ok: boolean; at: string | null }; visitorStats: { ok: boolean; at: string | null } }
|
||||
serverStartedAt: string
|
||||
}
|
||||
lastBroadcast: ResendStatus
|
||||
comments: { pending: number; thisWeek: number }
|
||||
activity: { at: string; kind: string; text: string }[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
publishedAt: string | null
|
||||
draftUpdatedAt: string | null
|
||||
isDirty: boolean
|
||||
onNavigate: (view: string) => void
|
||||
onRequestPublish: () => void
|
||||
}
|
||||
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const max = Math.max(1, ...values)
|
||||
const step = 100 / Math.max(1, values.length - 1)
|
||||
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(22 - (v / max) * 20).toFixed(1)}`).join(' ')
|
||||
return (
|
||||
<svg className="admin-dashboard-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendCard({ label, trend, onClick, actionLabel }: { label: string; trend: Trend; onClick?: () => void; actionLabel?: string }) {
|
||||
const { thisWeek, lastWeek } = trend
|
||||
const diff = thisWeek - lastWeek
|
||||
const pct = lastWeek > 0 ? Math.round((diff / lastWeek) * 100) : null
|
||||
const tone = diff > 0 ? 'up' : diff < 0 ? 'down' : 'flat'
|
||||
const arrow = diff > 0 ? '▲' : diff < 0 ? '▼' : '–'
|
||||
const delta = pct !== null ? `${arrow} ${Math.abs(pct)}%` : diff > 0 ? `${arrow} new` : '–'
|
||||
return (
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{thisWeek.toLocaleString()}</div>
|
||||
<div className="admin-dashboard-card-label">{label}</div>
|
||||
<div className="admin-dashboard-card-sub">
|
||||
<span className={`admin-dashboard-delta admin-dashboard-delta--${tone}`}>{delta}</span> vs {lastWeek.toLocaleString()} previous 7 days
|
||||
</div>
|
||||
<Sparkline values={trend.series} />
|
||||
{onClick && <button type="button" className="admin-dashboard-card-action" onClick={onClick}>{actionLabel} →</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWhen(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/admin-dashboard')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load dashboard data'))))
|
||||
.then(json => { if (!cancelled) setData(json as DashboardData) })
|
||||
.catch(() => { if (!cancelled) setFailed(true) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const draftNewer = !!draftUpdatedAt && (!publishedAt || new Date(draftUpdatedAt).getTime() > new Date(publishedAt).getTime())
|
||||
const unpublished = isDirty || draftNewer
|
||||
|
||||
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
||||
if (data) {
|
||||
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
|
||||
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
||||
if (data.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
|
||||
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
||||
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
||||
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{unpublished && (
|
||||
<div className="admin-dashboard-banner" role="status">
|
||||
<div>
|
||||
<strong>{isDirty ? 'You have unsaved edits.' : 'Draft changes are not live yet.'}</strong>
|
||||
<span> Draft updated {formatWhen(draftUpdatedAt)} · Live version published {formatWhen(publishedAt)}.</span>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-save" onClick={onRequestPublish}>Publish…</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-dashboard-quick">
|
||||
<span className="admin-dashboard-quick-label">Quick actions</span>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('broadcast')}>New Broadcast</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('downloads')}>Add Resource</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('qr-codes')}>QR Code</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate('questions')}>Questions</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={onRequestPublish}>Publish</button>
|
||||
</div>
|
||||
|
||||
{failed && <p className="admin-stats-note">Couldn't load the extra dashboard insights.</p>}
|
||||
{!data && !failed && <p className="admin-stats-note">Loading insights…</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<div className="admin-dashboard-attention">
|
||||
<h3 className="admin-dashboard-activity-heading">Needs attention</h3>
|
||||
<ul>
|
||||
{attention.map(item => (
|
||||
<li key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
{item.action && <button type="button" className="admin-dashboard-card-action" style={{ marginTop: 0 }} onClick={item.action}>{item.actionLabel} →</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
|
||||
<>
|
||||
<h3 className="admin-dashboard-section-title">Season check</h3>
|
||||
<div className="admin-dashboard-grid">
|
||||
{data.seasonCheck.lanes.map(l => {
|
||||
const issues = l.errors + l.warnings
|
||||
return (
|
||||
<div key={l.lane} className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
|
||||
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
|
||||
<div className="admin-dashboard-card-sub">
|
||||
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
|
||||
</div>
|
||||
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{data.seasonCheck.general > 0 && (
|
||||
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<h3 className="admin-dashboard-section-title">Trends</h3>
|
||||
<div className="admin-dashboard-grid">
|
||||
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
||||
<TrendCard label="New subscribers" trend={data.trends.subscribers} onClick={() => onNavigate('subscribers')} actionLabel="Subscribers" />
|
||||
<TrendCard label="New study accounts" trend={data.trends.studySignups} onClick={() => onNavigate('study-users')} actionLabel="Study users" />
|
||||
<div className="admin-dashboard-card">
|
||||
<div className="admin-dashboard-card-value">{data.comments.thisWeek}</div>
|
||||
<div className="admin-dashboard-card-label">Study comments (7 days)</div>
|
||||
<div className="admin-dashboard-card-sub">{data.comments.pending} awaiting approval</div>
|
||||
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('study-comments')}>Study comments →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity">
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Top pages (7 days)</h3>
|
||||
{data.traffic.topPages.length === 0 ? <p className="admin-stats-note">No visits recorded yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.traffic.topPages.map(p => (
|
||||
<div key={p.path} className="admin-dashboard-feed-item admin-dashboard-row"><span>{p.path}</span><strong>{p.hits}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Top referrers (7 days)</h3>
|
||||
{data.traffic.topReferrers.length === 0 ? <p className="admin-stats-note">No referrers recorded yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.traffic.topReferrers.map(r => (
|
||||
<div key={r.referrer} className="admin-dashboard-feed-item admin-dashboard-row"><span>{r.referrer}</span><strong>{r.count}</strong></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Last broadcast</h3>
|
||||
{!data.lastBroadcast.available ? <p className="admin-stats-note">{data.lastBroadcast.reason ?? 'Resend is unavailable.'}</p>
|
||||
: !data.lastBroadcast.broadcast ? <p className="admin-stats-note">No broadcasts sent yet.</p> : (
|
||||
<div className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{data.lastBroadcast.broadcast.subject || data.lastBroadcast.broadcast.name}</strong></div>
|
||||
<div className="admin-dashboard-feed-email">Sent {formatWhen(data.lastBroadcast.broadcast.sentAt)}</div>
|
||||
<a className="admin-dashboard-card-action" href={data.lastBroadcast.broadcast.dashboardUrl} target="_blank" rel="noreferrer">Opens, clicks & unsubscribes in Resend →</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Study re-engagement</h3>
|
||||
<div className="admin-dashboard-feed">
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 7–13 days</span><strong>{data.reengagement.t1}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 14–29 days</span><strong>{data.reengagement.t2}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Inactive 30+ days</span><strong>{data.reengagement.t3}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Reminders sent (30 days)</span><strong>{data.reengagement.recentlySent}</strong></div>
|
||||
<div className="admin-dashboard-card-sub">{data.reengagement.remindersEnabled} students have reminders on</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Study progress</h3>
|
||||
{data.studyProgress.studies.length === 0 ? <p className="admin-stats-note">No study activity yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.studyProgress.studies.map(s => (
|
||||
<div key={s.slug} className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{s.title}</strong></div>
|
||||
<div className="admin-dashboard-feed-preview">{s.enrolled} enrolled · {s.lessonsCompleted} lessons completed · {s.certificates} certificate{s.certificates === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-dashboard-card-sub">Last study activity: {formatWhen(data.studyProgress.lastActivityAt)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Latest podcast episode</h3>
|
||||
{!data.latestEpisode.available ? <p className="admin-stats-note">Couldn't read the podcast feed.</p> : (
|
||||
<div className="admin-dashboard-feed-item">
|
||||
<div className="admin-dashboard-feed-meta"><strong>{data.latestEpisode.title}</strong></div>
|
||||
<div className="admin-dashboard-feed-email">
|
||||
{data.latestEpisode.season ? `S${data.latestEpisode.season} ` : ''}{data.latestEpisode.episode ? `E${data.latestEpisode.episode} · ` : ''}
|
||||
{formatWhen(data.latestEpisode.publishedAt)}{data.latestEpisode.daysAgo != null ? ` (${data.latestEpisode.daysAgo} days ago)` : ''}
|
||||
</div>
|
||||
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">System health</h3>
|
||||
<div className="admin-dashboard-feed">
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Resend API key</span><strong>{data.health.resendConfigured ? 'Configured' : 'Missing'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Last backup</span><strong>{data.health.lastBackup.at ? `${data.health.lastBackup.ok ? '✓' : '✗'} ${formatWhen(data.health.lastBackup.at)}` : 'None this run'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Backups on disk</span><strong>{data.health.backupCount ?? '—'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Data directory</span><strong>{data.health.dataDirWritable === null ? '—' : data.health.dataDirWritable ? 'Writable' : 'Not writable'}</strong></div>
|
||||
<div className="admin-dashboard-feed-item admin-dashboard-row"><span>Server started</span><strong>{formatWhen(data.health.serverStartedAt)}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Recent activity</h3>
|
||||
{data.activity.length === 0 ? <p className="admin-stats-note">No activity yet.</p> : (
|
||||
<div className="admin-dashboard-feed">
|
||||
{data.activity.map((item, i) => (
|
||||
<div key={`${item.at}-${i}`} className="admin-dashboard-feed-item admin-dashboard-row">
|
||||
<span>{item.text}</span><span className="admin-dashboard-feed-date">{formatWhen(item.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+210
-186
@@ -27,11 +27,13 @@ ChartJS.register(
|
||||
ArcElement,
|
||||
)
|
||||
|
||||
type StatsRange = '7d' | '30d' | '90d' | 'all'
|
||||
|
||||
interface Props {
|
||||
stats: AdminStats | null
|
||||
statsStatus: 'loading' | 'error' | 'ready'
|
||||
statsRange?: '7d' | '30d' | '90d'
|
||||
onRangeChange?: (range: '7d' | '30d' | '90d') => void
|
||||
statsRange?: StatsRange
|
||||
onRangeChange?: (range: StatsRange) => void
|
||||
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
|
||||
formatDate: (date: string | null) => string
|
||||
maskIp: (ip: string) => string
|
||||
@@ -53,6 +55,37 @@ interface Props {
|
||||
onRefreshStatus: () => void
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<StatsRange, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
'all': 'All time',
|
||||
}
|
||||
|
||||
function fmtSec(s: number) {
|
||||
if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// Aggregate daily data into weekly buckets when there are many points (all-time)
|
||||
function aggregateTrend(data: Array<{ day: string; real: number; bot: number }>) {
|
||||
if (data.length <= 60) return data
|
||||
// Group by ISO week (YYYY-Www) — bucket by ~7 days
|
||||
const buckets: Record<string, { day: string; real: number; bot: number }> = {}
|
||||
for (const d of data) {
|
||||
const date = new Date(d.day)
|
||||
// Bucket key: year + week number
|
||||
const startOfYear = new Date(date.getFullYear(), 0, 1)
|
||||
const week = Math.ceil(((date.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7)
|
||||
const key = `${date.getFullYear()}-W${String(week).padStart(2, '0')}`
|
||||
if (!buckets[key]) buckets[key] = { day: d.day, real: 0, bot: 0 }
|
||||
buckets[key].real += d.real
|
||||
buckets[key].bot += d.bot
|
||||
}
|
||||
return Object.values(buckets)
|
||||
}
|
||||
|
||||
export function AnalyticsPanel({
|
||||
stats,
|
||||
statsStatus,
|
||||
@@ -60,7 +93,6 @@ export function AnalyticsPanel({
|
||||
onRangeChange,
|
||||
opsStatus,
|
||||
formatDate,
|
||||
maskIp,
|
||||
maintenanceMsg,
|
||||
selectedBackup,
|
||||
backupFiles,
|
||||
@@ -90,7 +122,6 @@ export function AnalyticsPanel({
|
||||
// Normalize fields that may be missing from older data
|
||||
const realHits = stats.realHits ?? 0
|
||||
const botHits = stats.botHits ?? 0
|
||||
const totalHits = stats.totalHits ?? 0
|
||||
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
|
||||
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
|
||||
@@ -99,51 +130,46 @@ export function AnalyticsPanel({
|
||||
const topPathsBot = stats.topPathsBot ?? []
|
||||
const botReasons = stats.botReasons ?? []
|
||||
|
||||
// Chart data - Daily trend
|
||||
const dailyChartData = {
|
||||
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day),
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? stats.totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? RANGE_LABELS[statsRange]
|
||||
const rawTrendData = (stats as AdminStats & { trendData?: Array<{ day: string; real: number; bot: number }> }).trendData
|
||||
// Fall back to last7Days format if trendData not present (old API)
|
||||
const trendPoints = rawTrendData
|
||||
? aggregateTrend(rawTrendData)
|
||||
: last7DaysReal.map((d: { day: string; hits: number }, i: number) => ({
|
||||
day: d.day,
|
||||
real: d.hits,
|
||||
bot: last7DaysBot[i]?.hits ?? 0,
|
||||
}))
|
||||
|
||||
const trendTitle = `Traffic Trend — ${rangeLabel}`
|
||||
const trendLabels = trendPoints.map(d => d.day.length === 10 ? d.day.slice(5) : d.day)
|
||||
|
||||
// Trend chart data
|
||||
const trendChartData = {
|
||||
labels: trendLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Visitors',
|
||||
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: trendPoints.map(d => d.real),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.1)',
|
||||
tension: 0.4,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: trendPoints.length > 90 ? 0 : 2,
|
||||
},
|
||||
{
|
||||
label: 'Bot Traffic',
|
||||
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
|
||||
data: trendPoints.map(d => d.bot),
|
||||
borderColor: '#666',
|
||||
backgroundColor: 'rgba(102, 102, 102, 0.1)',
|
||||
tension: 0.4,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: trendPoints.length > 90 ? 0 : 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Chart data - Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: [
|
||||
'#c9a84c',
|
||||
'#a0853d',
|
||||
'#6d5a2e',
|
||||
'#8b7635',
|
||||
'#9e8843',
|
||||
'#bbb477',
|
||||
'#7a6930',
|
||||
'#5a4820',
|
||||
'#c0a060',
|
||||
'#746c36',
|
||||
],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Traffic ratio (doughnut)
|
||||
const trafficRatioData = {
|
||||
labels: ['Real Visitors', 'Bot Traffic'],
|
||||
@@ -155,44 +181,30 @@ export function AnalyticsPanel({
|
||||
}],
|
||||
}
|
||||
|
||||
// Top paths comparison
|
||||
// Bot breakdown (pie)
|
||||
const botPieData = {
|
||||
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
|
||||
datasets: [{
|
||||
data: botReasons.map((b: { reason: string; count: number }) => b.count),
|
||||
backgroundColor: ['#c9a84c','#a0853d','#6d5a2e','#8b7635','#9e8843','#bbb477','#7a6930','#5a4820','#c0a060','#746c36'],
|
||||
borderColor: '#1a1a15',
|
||||
borderWidth: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Top paths
|
||||
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
|
||||
const realPath = topPathsReal[i]?.path || ''
|
||||
const botPath = topPathsBot[i]?.path || ''
|
||||
return realPath || botPath || `Path ${i + 1}`
|
||||
return topPathsReal[i]?.path || topPathsBot[i]?.path || `Path ${i + 1}`
|
||||
}).slice(0, 8)
|
||||
|
||||
const topPathsChartData = {
|
||||
labels: topPathsLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Real Hits',
|
||||
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
|
||||
backgroundColor: '#c9a84c',
|
||||
},
|
||||
{
|
||||
label: 'Bot Hits',
|
||||
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
|
||||
backgroundColor: '#999',
|
||||
},
|
||||
{ label: 'Real Hits', data: topPathsLabels.map((_l, i) => topPathsReal[i]?.hits || 0), backgroundColor: '#c9a84c' },
|
||||
{ label: 'Bot Hits', data: topPathsLabels.map((_l, i) => topPathsBot[i]?.hits || 0), backgroundColor: '#999' },
|
||||
],
|
||||
}
|
||||
|
||||
// 30-day trend chart
|
||||
const last30DaysReal = stats.visitors.last30DaysReal ?? []
|
||||
const thirtyDayChartData = {
|
||||
labels: last30DaysReal.map((d: { day: string; hits: number }) => d.day.slice(5)),
|
||||
datasets: [{
|
||||
label: 'Real Visitors',
|
||||
data: last30DaysReal.map((d: { day: string; hits: number }) => d.hits),
|
||||
borderColor: '#c9a84c',
|
||||
backgroundColor: 'rgba(201, 168, 76, 0.08)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 2,
|
||||
}],
|
||||
}
|
||||
|
||||
// Device breakdown (doughnut)
|
||||
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
|
||||
const deviceChartData = {
|
||||
@@ -209,41 +221,22 @@ export function AnalyticsPanel({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#f0ead8',
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#2a2518',
|
||||
titleColor: '#f0ead8',
|
||||
bodyColor: '#f0ead8',
|
||||
borderColor: '#c9a84c',
|
||||
borderWidth: 1,
|
||||
},
|
||||
legend: { labels: { color: '#f0ead8', font: { size: 12 } } },
|
||||
tooltip: { backgroundColor: '#2a2518', titleColor: '#f0ead8', bodyColor: '#f0ead8', borderColor: '#c9a84c', borderWidth: 1 },
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: '#7a7060' },
|
||||
grid: { color: '#2a2518' },
|
||||
},
|
||||
x: {
|
||||
ticks: { color: '#7a7060' },
|
||||
grid: { color: '#2a2518' },
|
||||
},
|
||||
y: { beginAtZero: true, ticks: { color: '#7a7060' }, grid: { color: '#2a2518' } },
|
||||
x: { ticks: { color: '#7a7060', maxTicksLimit: 12 }, grid: { color: '#2a2518' } },
|
||||
},
|
||||
}
|
||||
|
||||
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits
|
||||
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
|
||||
const rangeRealVisitors = stats.visitors.recentVisits.length
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Analytics">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Analytics Dashboard</h2>
|
||||
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
<p>Site traffic, bot detection, visitor behavior, and data management.</p>
|
||||
</div>
|
||||
|
||||
{/* Top-level panel tabs */}
|
||||
@@ -254,10 +247,10 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
|
||||
{panelTab === 'traffic' && (<>
|
||||
{/* Time-range tabs */}
|
||||
{/* Time-range selector */}
|
||||
{onRangeChange && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{(['7d', '30d', '90d'] as const).map(r => (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{(['7d', '30d', '90d', 'all'] as StatsRange[]).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
@@ -274,47 +267,59 @@ export function AnalyticsPanel({
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'}
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
|
||||
Showing: {rangeLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="admin-stats-grid">
|
||||
{/* KPI Grid — split into two clearly-labeled groups so a number from
|
||||
the selected range is never mistaken for an all-time total, or
|
||||
vice versa. Previously all six cards sat in one grid with three
|
||||
different implicit time windows (selected range, a hardcoded last
|
||||
30 days, and all-time), which read as contradictory when e.g. "7
|
||||
days" was selected but a "Last 30 Days" card sat right next to it. */}
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>{rangeLabel}</h3>
|
||||
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<article>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>{rangeLabel} all requests</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Real Traffic</h3>
|
||||
<h3>Real Hits</h3>
|
||||
<p>{realHits.toLocaleString()}</p>
|
||||
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>Human traffic</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Bot Traffic</h3>
|
||||
<p>{botHits.toLocaleString()}</p>
|
||||
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small>
|
||||
<small>{rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'}</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
<h3>Total Hits</h3>
|
||||
<p>{rangeTotalHits.toLocaleString()}</p>
|
||||
<small>Real + bot</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>All-Time</h3>
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>All-Time Real Hits</h3>
|
||||
<p>{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}</p>
|
||||
<small>Since {formatDate(stats.firstHitAt)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Unique Visitors</h3>
|
||||
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
|
||||
<small>Tracked with consent</small>
|
||||
<small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Returning Rate</h3>
|
||||
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
|
||||
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last 30 Days</h3>
|
||||
<p>{last30DaysRealTotal.toLocaleString()}</p>
|
||||
<small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Chart Tabs */}
|
||||
@@ -329,19 +334,23 @@ export function AnalyticsPanel({
|
||||
{chartTab === 'overview' && (
|
||||
<>
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
|
||||
<Line data={dailyChartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
|
||||
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">{trendTitle}</h3>
|
||||
<Line data={trendChartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3>
|
||||
<Line data={thirtyDayChartData} options={chartOptions} />
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Traffic Composition ({rangeLabel})</h3>
|
||||
<Doughnut data={trafficRatioData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Top Pages ({rangeLabel})</h3>
|
||||
<ul className="admin-bot-reason-list">
|
||||
{topPathsReal.slice(0, 8).map((item: { path: string; hits: number }) => (
|
||||
<li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -353,7 +362,7 @@ export function AnalyticsPanel({
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Device Breakdown</h3>
|
||||
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<Doughnut data={deviceChartData} options={chartOptions} />
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
<h3 className="admin-analytics-card-title">Device Counts</h3>
|
||||
@@ -371,7 +380,7 @@ export function AnalyticsPanel({
|
||||
{botReasons.length === 0 ? (
|
||||
<p className="admin-stats-note">No bot traffic detected.</p>
|
||||
) : (
|
||||
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
|
||||
<Pie data={botPieData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-analytics-card">
|
||||
@@ -390,20 +399,65 @@ export function AnalyticsPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
|
||||
{(stats.topUnknownPaths ?? []).length === 0 ? (
|
||||
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
|
||||
) : (
|
||||
<ul className="admin-bot-reason-list">
|
||||
{(stats.topUnknownPaths ?? []).map(row => (
|
||||
<li key={row.path} className="admin-bot-reason-item">
|
||||
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
|
||||
<strong>{row.hits.toLocaleString()}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Geographic Charts */}
|
||||
{chartTab === 'geographic' && (
|
||||
<div className="admin-analytics-geo">
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Pages</h3>
|
||||
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
|
||||
</div>
|
||||
<GeoMaps
|
||||
topCountries={stats.visitors.topCountries}
|
||||
topStates={stats.visitors.topStates}
|
||||
/>
|
||||
<div className="admin-stats-lists" style={{ marginTop: '1rem' }}>
|
||||
<div>
|
||||
<h3>Top Cities</h3>
|
||||
<ul>
|
||||
{(stats.visitors.topCities ?? []).slice(0, 10).map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top States</h3>
|
||||
<ul>
|
||||
{stats.visitors.topStates.slice(0, 10).map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top Countries</h3>
|
||||
<ul>
|
||||
{stats.visitors.topCountries.slice(0, 10).map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -411,7 +465,7 @@ export function AnalyticsPanel({
|
||||
{chartTab === 'referrers' && (
|
||||
<div className="admin-analytics-grid">
|
||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3>
|
||||
<h3 className="admin-analytics-card-title">Top Traffic Sources ({rangeLabel})</h3>
|
||||
{(stats.visitors.topReferrers ?? []).length === 0 ? (
|
||||
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
|
||||
) : (
|
||||
@@ -428,38 +482,21 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Visitors Table */}
|
||||
{/* Recent Visitors */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Visitor Details</h2>
|
||||
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
|
||||
<h2>Visitor Log</h2>
|
||||
<p>Activity for {rangeLabel.toLowerCase()} · {rangeRealVisitors} records shown.</p>
|
||||
</div>
|
||||
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
|
||||
<p className="admin-privacy-note">Analytics only run after cookie consent. Visitor IDs are pseudonymous hash prefixes.</p>
|
||||
|
||||
<div className="admin-stats-grid admin-stats-grid--visitor">
|
||||
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
|
||||
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
|
||||
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p><small>All time</small></article>
|
||||
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
||||
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-lists">
|
||||
<div>
|
||||
<h3>Top Countries</h3>
|
||||
<ul>
|
||||
{stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Top States</h3>
|
||||
<ul>
|
||||
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
|
||||
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* Top Countries/States/Cities live in the Geographic tab above, alongside
|
||||
the map — not repeated here to avoid showing the same numbers twice
|
||||
on one page. */}
|
||||
|
||||
<div className="admin-visits-table-wrap">
|
||||
<h3>Recent Visitor Log</h3>
|
||||
@@ -472,6 +509,9 @@ export function AnalyticsPanel({
|
||||
const isExpanded = expandedVisitor === rowKey
|
||||
const history = row.pageHistory ?? []
|
||||
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
|
||||
const visitorLabel = row.studyUser
|
||||
? (row.studyUser.displayName || row.studyUser.username)
|
||||
: `Visitor ${(row.ipHash ?? row.visitorId).slice(0, 8)}`
|
||||
return (
|
||||
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
|
||||
<button
|
||||
@@ -481,7 +521,9 @@ export function AnalyticsPanel({
|
||||
>
|
||||
<div className="admin-visitor-card__left">
|
||||
<span className="admin-visitor-card__name">
|
||||
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
|
||||
{row.studyUser
|
||||
? visitorLabel
|
||||
: <code className="admin-visitor-ip">{visitorLabel}</code>}
|
||||
</span>
|
||||
<span className="admin-visitor-card__path">{row.path}</span>
|
||||
</div>
|
||||
@@ -502,7 +544,7 @@ export function AnalyticsPanel({
|
||||
{isExpanded && (
|
||||
<div className="admin-visitor-card__detail">
|
||||
<div className="admin-visitor-card__detail-meta">
|
||||
<span><strong>IP</strong> {maskIp(row.ip)}</span>
|
||||
<span><strong>ID</strong> {(row.ipHash ?? row.visitorId).slice(0, 12)}…</span>
|
||||
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
|
||||
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
|
||||
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
|
||||
@@ -540,7 +582,12 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Episode Plays</h2>
|
||||
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p>
|
||||
<p>Counted each time a visitor presses play. One count per player mount.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<article><h3>Total Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
|
||||
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
|
||||
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p><small>{stats.episodePlays[0]?.total.toLocaleString()} plays</small></article>
|
||||
</div>
|
||||
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
|
||||
<Bar
|
||||
@@ -571,11 +618,6 @@ export function AnalyticsPanel({
|
||||
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
|
||||
<article><h3>Total Episode Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
|
||||
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
|
||||
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p></article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -584,10 +626,9 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Engagement</h2>
|
||||
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p>
|
||||
<p>Scroll depth, time on page, audio, and interaction data. Consent-gated.</p>
|
||||
</div>
|
||||
|
||||
{/* Time on page */}
|
||||
{stats.engagement.timeOnPage?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
|
||||
@@ -598,7 +639,7 @@ export function AnalyticsPanel({
|
||||
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
|
||||
<tr key={row.path}>
|
||||
<td>{row.path}</td>
|
||||
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td>
|
||||
<td>{fmtSec(row.avgSeconds)}</td>
|
||||
<td>{row.count.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -608,7 +649,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll depth */}
|
||||
{stats.engagement.scrollDepth?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
|
||||
@@ -631,7 +671,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio events */}
|
||||
{stats.engagement.audioEvents?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
|
||||
@@ -639,26 +678,20 @@ export function AnalyticsPanel({
|
||||
<table className="admin-stats-table">
|
||||
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
|
||||
<tbody>
|
||||
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => {
|
||||
const hrs = Math.floor(row.totalListenSeconds / 3600)
|
||||
const mins = Math.floor((row.totalListenSeconds % 3600) / 60)
|
||||
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m`
|
||||
return (
|
||||
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => (
|
||||
<tr key={row.title}>
|
||||
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
|
||||
<td>{row.completions.toLocaleString()}</td>
|
||||
<td>{row.pauses.toLocaleString()}</td>
|
||||
<td>{listenStr}</td>
|
||||
<td>{fmtSec(row.totalListenSeconds)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search queries */}
|
||||
{stats.engagement.topSearchQueries?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
|
||||
@@ -675,7 +708,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All link clicks */}
|
||||
{stats.engagement.topLinkClicks?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
|
||||
@@ -702,7 +734,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound clicks */}
|
||||
{stats.engagement.topOutboundClicks?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
|
||||
@@ -724,7 +755,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UTM sources */}
|
||||
{stats.engagement.topUTMSources?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
|
||||
@@ -741,7 +771,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 404s */}
|
||||
{stats.engagement.top404s?.length > 0 && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
|
||||
@@ -763,11 +792,11 @@ export function AnalyticsPanel({
|
||||
{/* Contact Summary */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Contact Summary</h2>
|
||||
<p>Submission totals from the contact form.</p>
|
||||
<p>All-time submission totals from the contact form.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid">
|
||||
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
||||
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
<article><h3>Total Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
||||
<article><h3>Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Study Enrollment Funnel */}
|
||||
@@ -775,7 +804,7 @@ export function AnalyticsPanel({
|
||||
<>
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Study Enrollment Funnel</h2>
|
||||
<p>How many users progress from signup → first visit → first section completed.</p>
|
||||
<p>Signup → first study visit → first section completed.</p>
|
||||
</div>
|
||||
<div className="admin-funnel">
|
||||
{(() => {
|
||||
@@ -805,10 +834,9 @@ export function AnalyticsPanel({
|
||||
</>)}
|
||||
|
||||
{panelTab === 'system' && (<>
|
||||
{/* Deployment & Cache Status */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment & Cache Status</h2>
|
||||
<p>Current deployment metadata and cache purge health for the live app.</p>
|
||||
<p>Current deployment metadata and cache purge health.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid">
|
||||
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
||||
@@ -817,7 +845,6 @@ export function AnalyticsPanel({
|
||||
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
|
||||
</div>
|
||||
|
||||
{/* Data Management */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Data Management</h2>
|
||||
<p>Export, backup, or retain only recent analytics data.</p>
|
||||
@@ -857,7 +884,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Data Backup (entire /data folder — server migration) */}
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Full Data Backup</h2>
|
||||
<p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server's environment.</p>
|
||||
@@ -869,8 +895,6 @@ export function AnalyticsPanel({
|
||||
</div>
|
||||
<div className="admin-restore-row">
|
||||
<label htmlFor="full-backup-file">Restore from File</label>
|
||||
{/* No accept filter: iPadOS greys out .tar.gz with one — the server
|
||||
validates the archive contents before restoring anyway. */}
|
||||
<input
|
||||
id="full-backup-file"
|
||||
type="file"
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
const MAX_MESSAGE = 3000
|
||||
|
||||
interface ContactFields {
|
||||
firstName: string
|
||||
@@ -12,6 +13,15 @@ interface ContactFields {
|
||||
messageType: string
|
||||
}
|
||||
|
||||
const TYPES: { value: string; label: string; hint: string; placeholder: string }[] = [
|
||||
{ value: 'question', label: 'Bible question', hint: 'Ask anything about Scripture. Answers are posted on the Q&A page.', placeholder: 'What would you like to know?' },
|
||||
{ value: 'gospel', label: 'Gospel courtroom', hint: 'A question about the gospel courtroom: the Judge, the accuser, the charges, the Substitute, the pardon or your plea. It goes to Nate privately and is not posted publicly.', placeholder: 'What would you like to ask about the courtroom, the verdict or the pardon?' },
|
||||
{ value: 'community', label: 'Community question', hint: 'A question about a study discussion or the community.', placeholder: 'What’s on your mind?' },
|
||||
{ value: 'testimony', label: 'Testimony', hint: 'Share what God is doing in your life. Every one is read.', placeholder: 'Tell your story…' },
|
||||
{ value: 'topic', label: 'Topic request', hint: 'Suggest a passage or topic for a future episode.', placeholder: 'Which passage or topic?' },
|
||||
{ value: 'general', label: 'General message', hint: 'Anything else you’d like to say.', placeholder: 'Write your message…' },
|
||||
]
|
||||
|
||||
export default function ContactForm() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -23,7 +33,7 @@ export default function ContactForm() {
|
||||
lastName: '',
|
||||
email: '',
|
||||
message: '',
|
||||
messageType: source === 'community' ? 'community' : 'question',
|
||||
messageType: source === 'community' ? 'community' : source === 'salvation' ? 'gospel' : 'question',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,6 +52,8 @@ export default function ContactForm() {
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
|
||||
|
||||
const activeType = TYPES.find(t => t.value === fields.messageType) ?? TYPES[0]
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||
const { name, value } = e.target
|
||||
setFields(f => ({ ...f, [name]: value }))
|
||||
@@ -59,10 +71,6 @@ export default function ContactForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('submitting')
|
||||
@@ -90,7 +98,7 @@ export default function ContactForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
||||
<form className="cx-form" onSubmit={handleSubmit} noValidate>
|
||||
<input
|
||||
type="text"
|
||||
className="contact-honeypot"
|
||||
@@ -100,14 +108,37 @@ export default function ContactForm() {
|
||||
value={honey}
|
||||
onChange={e => setHoney(e.target.value)}
|
||||
/>
|
||||
|
||||
<fieldset className="cx-types">
|
||||
<legend>What is this about?</legend>
|
||||
<div className="cx-type-row" role="radiogroup">
|
||||
{TYPES.map(t => (
|
||||
<label key={t.value} className="cx-type">
|
||||
<input
|
||||
type="radio"
|
||||
name="messageType"
|
||||
value={t.value}
|
||||
checked={fields.messageType === t.value}
|
||||
onChange={() => setFields(f => ({ ...f, messageType: t.value }))}
|
||||
/>
|
||||
<span>{t.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="cx-hint" aria-live="polite">{activeType.hint}</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="cx-row">
|
||||
<label>
|
||||
First Name
|
||||
First name
|
||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||||
</label>
|
||||
<label>
|
||||
Last Name
|
||||
Last name
|
||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
@@ -122,41 +153,37 @@ export default function ContactForm() {
|
||||
/>
|
||||
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
|
||||
</label>
|
||||
<label>
|
||||
Message Type
|
||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
||||
<option value="question">Bible Question</option>
|
||||
<option value="community">Community 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>
|
||||
{fields.messageType === 'question' && (
|
||||
<label className="contact-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyOnAnswer}
|
||||
onChange={e => setNotifyOnAnswer(e.target.checked)}
|
||||
<textarea
|
||||
name="message"
|
||||
rows={6}
|
||||
required
|
||||
maxLength={MAX_MESSAGE}
|
||||
placeholder={activeType.placeholder}
|
||||
value={fields.message}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<span className="cx-count">{fields.message.length} / {MAX_MESSAGE}</span>
|
||||
</label>
|
||||
|
||||
<div className="cx-checks">
|
||||
{fields.messageType === 'question' && (
|
||||
<label className="cx-check">
|
||||
<input type="checkbox" checked={notifyOnAnswer} onChange={e => setNotifyOnAnswer(e.target.checked)} />
|
||||
<span>Email me when this question is answered.</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="contact-consent">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subscribe}
|
||||
onChange={e => setSubscribe(e.target.checked)}
|
||||
/>
|
||||
<label className="cx-check">
|
||||
<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'}
|
||||
</div>
|
||||
|
||||
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
|
||||
<button type="submit" className="btn-primary cx-submit" disabled={status === 'submitting'}>
|
||||
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { sendEvent } from '../analytics'
|
||||
import { sendEvent, checkAnalyticsConsentResponse } from '../analytics'
|
||||
|
||||
interface EpisodeAudioPlayerProps {
|
||||
src: string
|
||||
@@ -101,7 +101,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
}).catch(() => {})
|
||||
}).then(checkAnalyticsConsentResponse).catch(() => {})
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const TYPE_LABEL: Record<SearchResultType, string> = {
|
||||
question: 'Q&A',
|
||||
resource: 'Download',
|
||||
series: 'Series',
|
||||
newsletter: 'Newsletter',
|
||||
}
|
||||
|
||||
const TYPE_ICON: Record<SearchResultType, string> = {
|
||||
@@ -15,6 +16,7 @@ const TYPE_ICON: Record<SearchResultType, string> = {
|
||||
question: '❓',
|
||||
resource: '📄',
|
||||
series: '📚',
|
||||
newsletter: '✉️',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -57,6 +59,9 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
|
||||
setQuery('')
|
||||
if (result.href.startsWith('http')) {
|
||||
window.open(result.href, '_blank', 'noreferrer')
|
||||
} else if (result.type === 'newsletter') {
|
||||
// Newsletter issues are server-rendered outside the SPA (for social-share OG tags), so use a full navigation.
|
||||
window.location.href = result.href
|
||||
} else {
|
||||
navigate(result.href)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface AccountStatus {
|
||||
authenticated: boolean
|
||||
username: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
adminShortcut?: boolean
|
||||
}
|
||||
|
||||
// Shared across header instances so moving between pages doesn't flash the signed-out icon
|
||||
let cachedStatus: AccountStatus | null = null
|
||||
|
||||
function PersonIcon() {
|
||||
return (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HeaderAccount() {
|
||||
const [status, setStatus] = useState<AccountStatus | null>(cachedStatus)
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/study-auth/status')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('status failed'))))
|
||||
.then((data: AccountStatus) => {
|
||||
cachedStatus = data
|
||||
if (!cancelled) setStatus(data)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('touchstart', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('touchstart', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function signOut() {
|
||||
try { await fetch('/api/study-auth/logout', { method: 'POST' }) } catch { /* reload below shows the real state */ }
|
||||
cachedStatus = null
|
||||
window.location.assign('/')
|
||||
}
|
||||
|
||||
// Signed out (or still checking): one tap goes to the sign-in page
|
||||
if (!status?.authenticated) {
|
||||
return (
|
||||
<Link to="/study/account" className="header-account" aria-label="Sign in or create an account" title="Sign in">
|
||||
<PersonIcon />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const name = status.displayName?.trim() || status.username
|
||||
const initial = (name || '?').charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="header-account-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-account header-account--in"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`Account menu for ${name}`}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{status.avatarUrl ? <img src={status.avatarUrl} alt="" className="header-account-avatar" /> : <span aria-hidden="true">{initial}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="header-account-menu" role="menu">
|
||||
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
|
||||
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
|
||||
{status.adminShortcut && (
|
||||
<Link to="/admin" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>Admin</Link>
|
||||
)}
|
||||
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,17 @@ import { useEffect } from 'react'
|
||||
interface NoteCardProps {
|
||||
anchorLabel: string
|
||||
html: string
|
||||
updatedAt?: string | null
|
||||
onDelete?: () => void
|
||||
autoSaveMsg?: string
|
||||
onChange: (html: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
||||
export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
shouldRerenderOnTransaction: true,
|
||||
content: html || '<p></p>',
|
||||
onUpdate({ editor }) {
|
||||
const next = editor.getHTML()
|
||||
@@ -32,6 +35,8 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
const wordCount = editor.getText().split(/\s+/).filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="note-card">
|
||||
<div className="note-card-header">
|
||||
@@ -71,6 +76,27 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
||||
aria-label="Numbered list"
|
||||
title="Numbered list"
|
||||
>1.</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('heading', { level: 3 }) ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleHeading({ level: 3 }).run() }}
|
||||
aria-label="Heading"
|
||||
title="Heading"
|
||||
>H</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('blockquote') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run() }}
|
||||
aria-label="Quote"
|
||||
title="Quote"
|
||||
>❝</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`note-tool${editor.isActive('strike') ? ' note-tool--active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleStrike().run() }}
|
||||
aria-label="Strikethrough"
|
||||
title="Strikethrough"
|
||||
><s>S</s></button>
|
||||
<div className="note-tool-divider" />
|
||||
<button
|
||||
type="button"
|
||||
@@ -91,6 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
||||
</div>
|
||||
|
||||
<EditorContent editor={editor} className="note-card-editor" />
|
||||
<div className="note-card-footer">
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="note-card-delete"
|
||||
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
|
||||
>Delete note</button>
|
||||
)}
|
||||
<span>
|
||||
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
|
||||
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const DRAFT_KEY = 'nuggets_ep_draft'
|
||||
|
||||
interface HistoryEntry {
|
||||
episodeName: string
|
||||
passage: string
|
||||
filename: string
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
type Screen = 'checking' | 'login' | 'otp' | 'portal' | 'settings'
|
||||
|
||||
function formatBytes(b: number) {
|
||||
if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
|
||||
if (b >= 1024) return `${(b / 1024).toFixed(0)} KB`
|
||||
return `${b} B`
|
||||
}
|
||||
|
||||
function saveDraft(ep: string, passage: string, desc: string) {
|
||||
try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ep, passage, desc })) } catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as { ep: string; passage: string; desc: string }
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try { localStorage.removeItem(DRAFT_KEY) } catch { /* storage unavailable */ }
|
||||
}
|
||||
|
||||
export function NuggetsHostPortal() {
|
||||
const [screen, setScreen] = useState<Screen>('checking')
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
||||
const [twoFaEnabled, setTwoFaEnabled] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [guidelines, setGuidelines] = useState<string[]>([
|
||||
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
|
||||
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
|
||||
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
|
||||
'Normalize to -1 dBFS before exporting.',
|
||||
'Match the episode name here to the file name exactly so search stays clean.',
|
||||
])
|
||||
|
||||
// Login state
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loginError, setLoginError] = useState('')
|
||||
const [loggingIn, setLoggingIn] = useState(false)
|
||||
|
||||
// OTP state
|
||||
const [otpToken, setOtpToken] = useState('')
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [otpError, setOtpError] = useState('')
|
||||
const [verifyingOtp, setVerifyingOtp] = useState(false)
|
||||
|
||||
// Upload form state
|
||||
const [episodeName, setEpisodeName] = useState('')
|
||||
const [passage, setPassage] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [fileWarning, setFileWarning] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const [lastSuccess, setLastSuccess] = useState<{ episodeName: string; filename: string; seafileUrl?: string } | null>(null)
|
||||
const [guidelinesOpen, setGuidelinesOpen] = useState(false)
|
||||
|
||||
// History
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
|
||||
// Settings state
|
||||
const [currentPw, setCurrentPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [pwError, setPwError] = useState('')
|
||||
const [pwSuccess, setPwSuccess] = useState('')
|
||||
const [savingPw, setSavingPw] = useState(false)
|
||||
const [toggling2fa, setToggling2fa] = useState(false)
|
||||
const [twoFaMsg, setTwoFaMsg] = useState('')
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load session on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/nuggets-upload/me')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.authenticated) {
|
||||
setHostName(d.hostName ?? '')
|
||||
setSeafileConfigured(d.configured !== false)
|
||||
setTwoFaEnabled(Boolean(d.twoFaEnabled))
|
||||
setScreen('portal')
|
||||
const draft = loadDraft()
|
||||
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
|
||||
} else {
|
||||
setScreen('login')
|
||||
}
|
||||
})
|
||||
.catch(() => setScreen('login'))
|
||||
}, [])
|
||||
|
||||
// Load announcement and guidelines
|
||||
useEffect(() => {
|
||||
fetch('/api/nuggets-upload/announcement')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.text) setAnnouncement(d.text) })
|
||||
.catch(() => {})
|
||||
fetch('/api/nuggets-upload/guidelines')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d.lines) && d.lines.length) setGuidelines(d.lines) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load history when history panel is opened
|
||||
useEffect(() => {
|
||||
if (historyOpen && screen === 'portal') {
|
||||
fetch('/api/nuggets-upload/history')
|
||||
.then(r => r.json())
|
||||
.then(d => setHistory(d.entries ?? []))
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [historyOpen, screen])
|
||||
|
||||
// Autosave draft
|
||||
useEffect(() => {
|
||||
if (screen === 'portal') saveDraft(episodeName, passage, description)
|
||||
}, [episodeName, passage, description, screen])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoginError('')
|
||||
setLoggingIn(true)
|
||||
try {
|
||||
const r = await fetch('/api/nuggets-upload/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
|
||||
if (d.requiresOtp) {
|
||||
setOtpToken(d.otpToken)
|
||||
setScreen('otp')
|
||||
return
|
||||
}
|
||||
setHostName(d.hostName)
|
||||
setTwoFaEnabled(false)
|
||||
setScreen('portal')
|
||||
const draft = loadDraft()
|
||||
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
|
||||
} finally {
|
||||
setLoggingIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyOtp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setOtpError('')
|
||||
setVerifyingOtp(true)
|
||||
try {
|
||||
const r = await fetch('/api/nuggets-upload/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ otpToken, code: otpCode }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setOtpError(d.message ?? 'Verification failed.'); return }
|
||||
setHostName(d.hostName)
|
||||
setTwoFaEnabled(true)
|
||||
setScreen('portal')
|
||||
const draft = loadDraft()
|
||||
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
|
||||
} finally {
|
||||
setVerifyingOtp(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
|
||||
clearDraft()
|
||||
setHostName('')
|
||||
setEmail('')
|
||||
setPassword('')
|
||||
setEpisodeName('')
|
||||
setPassage('')
|
||||
setDescription('')
|
||||
setFile(null)
|
||||
setLastSuccess(null)
|
||||
setHistory([])
|
||||
setScreen('login')
|
||||
}
|
||||
|
||||
function pickFile(picked: File | null) {
|
||||
if (!picked) return
|
||||
setFileWarning('')
|
||||
if (picked.size > 500 * 1024 * 1024) {
|
||||
setFileWarning('File exceeds 500 MB limit. Please compress or trim before uploading.')
|
||||
return
|
||||
}
|
||||
if (picked.size < 500 * 1024) {
|
||||
setFileWarning(`File is only ${formatBytes(picked.size)} — unusually small. Check the recording before submitting.`)
|
||||
}
|
||||
setFile(picked)
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
pickFile(e.dataTransfer.files[0] ?? null)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
pickFile(e.target.files?.[0] ?? null)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!file) { setUploadError('Please select an audio file.'); return }
|
||||
if (uploading) return
|
||||
|
||||
setUploadError('')
|
||||
setLastSuccess(null)
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('episodeName', episodeName)
|
||||
formData.append('passage', passage)
|
||||
formData.append('description', description)
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/nuggets-upload/upload')
|
||||
xhr.upload.addEventListener('progress', ev => {
|
||||
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
|
||||
})
|
||||
xhr.addEventListener('load', () => {
|
||||
setUploading(false)
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText)
|
||||
setLastSuccess({ episodeName: d.episodeName, filename: d.filename, seafileUrl: d.seafileUrl })
|
||||
clearDraft()
|
||||
setEpisodeName('')
|
||||
setPassage('')
|
||||
setDescription('')
|
||||
setFile(null)
|
||||
setFileWarning('')
|
||||
setProgress(0)
|
||||
} catch {
|
||||
setUploadError('Upload succeeded but the response was unexpected.')
|
||||
}
|
||||
} else {
|
||||
try { setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.') }
|
||||
catch { setUploadError('Upload failed.') }
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
xhr.addEventListener('error', () => {
|
||||
setUploading(false)
|
||||
setUploadError('Network error during upload.')
|
||||
resolve()
|
||||
})
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setPwError('')
|
||||
setPwSuccess('')
|
||||
if (newPw !== confirmPw) { setPwError('New passwords do not match.'); return }
|
||||
if (newPw.length < 8) { setPwError('Password must be at least 8 characters.'); return }
|
||||
setSavingPw(true)
|
||||
try {
|
||||
const r = await fetch('/api/nuggets-upload/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setPwError(d.message ?? 'Failed to change password.'); return }
|
||||
setPwSuccess('Password changed successfully.')
|
||||
setCurrentPw('')
|
||||
setNewPw('')
|
||||
setConfirmPw('')
|
||||
} finally {
|
||||
setSavingPw(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handle2faToggle() {
|
||||
setToggling2fa(true)
|
||||
setTwoFaMsg('')
|
||||
try {
|
||||
const url = twoFaEnabled ? '/api/nuggets-upload/2fa/disable' : '/api/nuggets-upload/2fa/enable'
|
||||
const r = await fetch(url, { method: 'POST' })
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setTwoFaMsg(d.message ?? 'Failed.'); return }
|
||||
setTwoFaEnabled(!twoFaEnabled)
|
||||
setTwoFaMsg(twoFaEnabled ? '2FA disabled.' : '2FA enabled. A code will be sent to your email each time you sign in.')
|
||||
} finally {
|
||||
setToggling2fa(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screens ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (screen === 'checking') {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<p style={{ color: '#888', textAlign: 'center', margin: 0 }}>Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'login') {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<div style={s.logoRow}>
|
||||
<span style={s.logoEmoji}>🎙️</span>
|
||||
<span style={s.logoTitle}>Nuggets</span>
|
||||
</div>
|
||||
<h1 style={s.heading}>Host Upload Portal</h1>
|
||||
<p style={s.subText}>Sign in with your host credentials to submit an episode.</p>
|
||||
<form onSubmit={handleLogin} style={s.form}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="hp-u">Email</label>
|
||||
<input id="hp-u" style={s.input} type="email" autoComplete="email"
|
||||
value={email} onChange={e => setEmail(e.target.value)} disabled={loggingIn} required />
|
||||
</div>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="hp-p">Password</label>
|
||||
<input id="hp-p" style={s.input} type="password" autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)} disabled={loggingIn} required />
|
||||
</div>
|
||||
{loginError && <p style={s.errorMsg}>{loginError}</p>}
|
||||
<button style={s.btnPrimary} type="submit" disabled={loggingIn}>
|
||||
{loggingIn ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'otp') {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={s.card}>
|
||||
<div style={s.logoRow}>
|
||||
<span style={s.logoEmoji}>🎙️</span>
|
||||
<span style={s.logoTitle}>Nuggets</span>
|
||||
</div>
|
||||
<h1 style={s.heading}>Check Your Email</h1>
|
||||
<p style={s.subText}>A 6-digit code was sent to your email address. Enter it below to continue.</p>
|
||||
<form onSubmit={handleVerifyOtp} style={s.form}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="otp-code">Sign-In Code</label>
|
||||
<input
|
||||
id="otp-code"
|
||||
style={{ ...s.input, textAlign: 'center', letterSpacing: '0.3em', fontSize: '1.3rem', fontWeight: 700 }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={otpCode}
|
||||
onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
disabled={verifyingOtp}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{otpError && <p style={s.errorMsg}>{otpError}</p>}
|
||||
<button style={s.btnPrimary} type="submit" disabled={verifyingOtp || otpCode.length !== 6}>
|
||||
{verifyingOtp ? 'Verifying…' : 'Verify Code'}
|
||||
</button>
|
||||
<button style={s.btnGhost} type="button" onClick={() => { setScreen('login'); setOtpCode(''); setOtpError('') }}>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (screen === 'settings') {
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={{ ...s.card, maxWidth: 460 }}>
|
||||
{/* Settings header */}
|
||||
<div style={s.header}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button style={s.btnIcon} onClick={() => { setPwError(''); setPwSuccess(''); setTwoFaMsg(''); setScreen('portal') }} title="Back">
|
||||
←
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--brand-warm-white, #f0ead8)' }}>Account Settings</span>
|
||||
</div>
|
||||
<span style={s.welcomeText}>{hostName}</span>
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div style={s.settingsSection}>
|
||||
<h2 style={s.sectionTitle}>Change Password</h2>
|
||||
<form onSubmit={handleChangePassword} style={s.form}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="cur-pw">Current Password</label>
|
||||
<input id="cur-pw" style={s.input} type="password" autoComplete="current-password"
|
||||
value={currentPw} onChange={e => setCurrentPw(e.target.value)} disabled={savingPw} required />
|
||||
</div>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="new-pw">New Password</label>
|
||||
<input id="new-pw" style={s.input} type="password" autoComplete="new-password"
|
||||
value={newPw} onChange={e => setNewPw(e.target.value)} disabled={savingPw} required />
|
||||
</div>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="conf-pw">Confirm New Password</label>
|
||||
<input id="conf-pw" style={s.input} type="password" autoComplete="new-password"
|
||||
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} disabled={savingPw} required />
|
||||
</div>
|
||||
{pwError && <p style={s.errorMsg}>{pwError}</p>}
|
||||
{pwSuccess && <p style={s.successMsg}>{pwSuccess}</p>}
|
||||
<button style={s.btnPrimary} type="submit" disabled={savingPw}>
|
||||
{savingPw ? 'Saving…' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 2FA */}
|
||||
<div style={{ ...s.settingsSection, marginTop: 24 }}>
|
||||
<h2 style={s.sectionTitle}>Two-Factor Authentication</h2>
|
||||
<p style={{ ...s.subText, margin: '0 0 12px' }}>
|
||||
{twoFaEnabled
|
||||
? 'Email 2FA is currently enabled. You receive a code by email each time you sign in.'
|
||||
: 'Email 2FA is off. Enable it to receive a one-time code by email each time you sign in.'}
|
||||
</p>
|
||||
{twoFaMsg && <p style={twoFaEnabled ? s.successMsg : s.errorMsg}>{twoFaMsg}</p>}
|
||||
<button
|
||||
style={twoFaEnabled ? s.btnDanger : s.btnPrimary}
|
||||
type="button"
|
||||
onClick={handle2faToggle}
|
||||
disabled={toggling2fa}
|
||||
>
|
||||
{toggling2fa ? '…' : twoFaEnabled ? 'Disable 2FA' : 'Enable 2FA'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sign out */}
|
||||
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--brand-border, #2a2518)' }}>
|
||||
<button style={s.btnGhost} type="button" onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Portal (main upload screen) ────────────────────────────────────────────
|
||||
return (
|
||||
<div style={s.page}>
|
||||
<div style={{ ...s.card, maxWidth: 560 }}>
|
||||
{/* Header */}
|
||||
<div style={s.header}>
|
||||
<div style={s.logoRow}>
|
||||
<span style={s.logoEmoji}>🎙️</span>
|
||||
<span style={s.logoTitle}>Nuggets Upload Portal</span>
|
||||
</div>
|
||||
<div style={s.headerRight}>
|
||||
<span style={s.welcomeText}>{hostName}</span>
|
||||
<button style={s.btnIcon} title="Settings" onClick={() => setScreen('settings')}>⚙</button>
|
||||
<button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Announcement banner */}
|
||||
{announcement && (
|
||||
<div style={s.announcement}>
|
||||
<span style={{ marginRight: 6 }}>📢</span>{announcement}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!seafileConfigured && (
|
||||
<div style={s.warning}>
|
||||
Seafile is not configured yet — contact the admin before uploading.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success banner */}
|
||||
{lastSuccess && (
|
||||
<div style={s.successBanner}>
|
||||
<p style={{ margin: '0 0 4px', fontWeight: 700, color: 'var(--brand-gold, #c9a84c)' }}>
|
||||
✓ Episode submitted successfully
|
||||
</p>
|
||||
<p style={{ margin: '0 0 6px', fontSize: '0.87rem', color: '#c8c0ac' }}>
|
||||
{lastSuccess.episodeName} · {lastSuccess.filename}
|
||||
</p>
|
||||
{lastSuccess.seafileUrl && (
|
||||
<a href={lastSuccess.seafileUrl} target="_blank" rel="noopener noreferrer" style={s.seafileLink}>
|
||||
View in Seafile →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recording guidelines (collapsible) */}
|
||||
<div style={s.collapsible}>
|
||||
<button
|
||||
style={s.collapsibleToggle}
|
||||
type="button"
|
||||
onClick={() => setGuidelinesOpen(o => !o)}
|
||||
>
|
||||
<span>📋 Recording Guidelines</span>
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{guidelinesOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{guidelinesOpen && (
|
||||
<div style={s.collapsibleBody}>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||||
{guidelines.map((line, i) => <li key={i}>{line}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload form */}
|
||||
<form onSubmit={handleSubmit} style={s.form}>
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
|
||||
<input id="ep-name" style={s.input} type="text"
|
||||
placeholder="e.g. The Grace of God in Romans 5"
|
||||
value={episodeName} onChange={e => setEpisodeName(e.target.value)} disabled={uploading} required />
|
||||
</div>
|
||||
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
|
||||
<input id="ep-passage" style={s.input} type="text"
|
||||
placeholder="e.g. Romans 5:1-11"
|
||||
value={passage} onChange={e => setPassage(e.target.value)} disabled={uploading} required />
|
||||
</div>
|
||||
|
||||
<div style={s.field}>
|
||||
<label style={s.label} htmlFor="ep-desc">Description</label>
|
||||
<textarea id="ep-desc" style={{ ...s.input, resize: 'vertical', minHeight: 80 } as React.CSSProperties}
|
||||
placeholder="A short summary of what this episode covers…"
|
||||
value={description} onChange={e => setDescription(e.target.value)} disabled={uploading} rows={3} />
|
||||
</div>
|
||||
|
||||
<div style={s.field}>
|
||||
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
|
||||
<div
|
||||
style={{
|
||||
...s.dropZone,
|
||||
...(dragging ? s.dropZoneActive : {}),
|
||||
...(uploading ? s.dropZoneDisabled : {}),
|
||||
...(file ? s.dropZoneHasFile : {}),
|
||||
}}
|
||||
onDragOver={e => { e.preventDefault(); if (!uploading) setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
|
||||
style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
{file ? (
|
||||
<div style={s.fileInfo}>
|
||||
<span style={s.fileIcon}>🎵</span>
|
||||
<div>
|
||||
<p style={s.fileName}>{file.name}</p>
|
||||
<p style={s.fileSize}>{formatBytes(file.size)} — click to change</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span style={s.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
|
||||
<p style={s.dropLabel}>{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}</p>
|
||||
<p style={s.dropSub}>MP3, WAV, M4A, FLAC · max 500 MB</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{fileWarning && <p style={s.warnMsg}>{fileWarning}</p>}
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div style={s.progressWrap}>
|
||||
<div style={s.progressBar}>
|
||||
<div style={{ ...s.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
<p style={s.progressLabel}>Uploading… {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && <p style={s.errorMsg}>{uploadError}</p>}
|
||||
|
||||
<button style={{ ...s.btnPrimary, marginTop: 4 }} type="submit" disabled={uploading || !seafileConfigured}>
|
||||
{uploading ? 'Uploading…' : 'Submit Episode'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Episode History (collapsible) */}
|
||||
<div style={{ ...s.collapsible, marginTop: 12 }}>
|
||||
<button style={s.collapsibleToggle} type="button" onClick={() => setHistoryOpen(o => !o)}>
|
||||
<span>🕐 Your Upload History</span>
|
||||
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{historyOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{historyOpen && (
|
||||
<div style={s.collapsibleBody}>
|
||||
{history.length === 0 ? (
|
||||
<p style={{ margin: 0, color: 'var(--brand-muted, #b0a48c)', fontSize: '0.85rem' }}>No uploads yet.</p>
|
||||
) : (
|
||||
history.map((entry, i) => (
|
||||
<div key={i} style={{ padding: '6px 0', borderBottom: i < history.length - 1 ? '1px solid var(--brand-border, #2a2518)' : undefined }}>
|
||||
<p style={{ margin: '0 0 2px', fontWeight: 600, fontSize: '0.87rem', color: 'var(--brand-warm-white, #f0ead8)' }}>
|
||||
{entry.episodeName}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '0.78rem', color: 'var(--brand-muted, #b0a48c)' }}>
|
||||
{entry.passage} · {entry.filename} · {new Date(entry.uploadedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────
|
||||
const s: Record<string, React.CSSProperties> = {
|
||||
page: {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem 1rem',
|
||||
background: 'var(--brand-black, #0a0a08)',
|
||||
},
|
||||
card: {
|
||||
background: 'var(--brand-black-2, #0f0f0c)',
|
||||
border: '1px solid var(--brand-border, #2a2518)',
|
||||
borderRadius: 16,
|
||||
padding: '2.25rem 2rem',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
|
||||
},
|
||||
logoRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 },
|
||||
logoEmoji: { fontSize: '1.5rem' },
|
||||
logoTitle: { fontWeight: 700, fontSize: '1rem', color: 'var(--brand-gold, #c9a84c)' },
|
||||
heading: { fontSize: '1.4rem', fontWeight: 700, margin: '0 0 6px', color: 'var(--brand-warm-white, #f0ead8)' },
|
||||
subText: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.88rem', margin: '0 0 1.4rem' },
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
headerRight: { display: 'flex', alignItems: 'center', gap: 8, paddingTop: 2 },
|
||||
welcomeText: { fontSize: '0.85rem', color: 'var(--brand-muted, #b0a48c)', fontWeight: 500 },
|
||||
btnGhost: {
|
||||
background: 'none',
|
||||
border: '1px solid var(--brand-border, #2a2518)',
|
||||
borderRadius: 6,
|
||||
padding: '0.3rem 0.65rem',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--brand-muted, #b0a48c)',
|
||||
},
|
||||
btnIcon: {
|
||||
background: 'none',
|
||||
border: '1px solid var(--brand-border, #2a2518)',
|
||||
borderRadius: 6,
|
||||
padding: '0.3rem 0.55rem',
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--brand-muted, #b0a48c)',
|
||||
lineHeight: 1,
|
||||
},
|
||||
btnPrimary: {
|
||||
background: 'var(--brand-gold, #c9a84c)',
|
||||
color: '#0a0a08',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '0.7rem 1rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
},
|
||||
btnDanger: {
|
||||
background: '#8b2020',
|
||||
color: '#f0ead8',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
padding: '0.7rem 1rem',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
},
|
||||
announcement: {
|
||||
background: 'rgba(201,168,76,0.1)',
|
||||
border: '1px solid var(--brand-gold, #c9a84c)',
|
||||
borderRadius: 8,
|
||||
padding: '0.65rem 0.9rem',
|
||||
fontSize: '0.87rem',
|
||||
marginBottom: 16,
|
||||
color: 'var(--brand-warm-white, #f0ead8)',
|
||||
},
|
||||
warning: {
|
||||
background: 'rgba(200,169,110,0.1)',
|
||||
border: '1px solid #8a6e28',
|
||||
borderRadius: 8,
|
||||
padding: '0.6rem 0.85rem',
|
||||
fontSize: '0.84rem',
|
||||
marginBottom: 16,
|
||||
color: '#e0c070',
|
||||
},
|
||||
successBanner: {
|
||||
background: 'rgba(50,120,50,0.15)',
|
||||
border: '1px solid #3a6a3a',
|
||||
borderRadius: 10,
|
||||
padding: '0.85rem 1rem',
|
||||
marginBottom: 16,
|
||||
},
|
||||
seafileLink: {
|
||||
color: 'var(--brand-gold, #c9a84c)',
|
||||
fontSize: '0.84rem',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: { display: 'flex', flexDirection: 'column', gap: 14 },
|
||||
field: { display: 'flex', flexDirection: 'column', gap: 5 },
|
||||
label: { fontSize: '0.85rem', fontWeight: 600, color: 'var(--brand-warm-white, #f0ead8)' },
|
||||
req: { color: '#e07070' },
|
||||
input: {
|
||||
padding: '0.55rem 0.75rem',
|
||||
border: '1px solid var(--brand-border, #2a2518)',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.93rem',
|
||||
background: 'var(--brand-black-3, #1a1a15)',
|
||||
color: 'var(--brand-warm-white, #f0ead8)',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
dropZone: {
|
||||
border: '2px dashed var(--brand-border, #2a2518)',
|
||||
borderRadius: 10,
|
||||
padding: '1.4rem 1rem',
|
||||
textAlign: 'center' as const,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
userSelect: 'none' as const,
|
||||
color: 'var(--brand-warm-white, #f0ead8)',
|
||||
},
|
||||
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
|
||||
dropZoneDisabled: { cursor: 'default', opacity: 0.5 },
|
||||
dropZoneHasFile: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.04)' },
|
||||
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
|
||||
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
|
||||
dropSub: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.8rem', margin: 0 },
|
||||
fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' as const },
|
||||
fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
|
||||
fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all' as const, color: 'var(--brand-warm-white, #f0ead8)' },
|
||||
fileSize: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.78rem', margin: 0 },
|
||||
progressWrap: { display: 'flex', flexDirection: 'column' as const, gap: 5 },
|
||||
progressBar: { height: 7, background: 'var(--brand-black-3, #1a1a15)', borderRadius: 4, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', background: 'var(--brand-gold, #c9a84c)', borderRadius: 4, transition: 'width 0.2s' },
|
||||
progressLabel: { fontSize: '0.82rem', color: 'var(--brand-muted, #b0a48c)', margin: 0, textAlign: 'center' as const },
|
||||
errorMsg: { color: '#e07070', fontSize: '0.84rem', margin: '2px 0' },
|
||||
warnMsg: { color: '#e0c070', fontSize: '0.82rem', margin: '2px 0' },
|
||||
successMsg: { color: '#6db86d', fontSize: '0.84rem', margin: '2px 0' },
|
||||
collapsible: {
|
||||
border: '1px solid var(--brand-border, #2a2518)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
collapsibleToggle: {
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--brand-warm-white, #f0ead8)',
|
||||
fontSize: '0.87rem',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'left' as const,
|
||||
},
|
||||
collapsibleBody: {
|
||||
padding: '0.75rem 1rem',
|
||||
fontSize: '0.84rem',
|
||||
color: 'var(--brand-muted, #b0a48c)',
|
||||
borderTop: '1px solid var(--brand-border, #2a2518)',
|
||||
},
|
||||
code: {
|
||||
fontFamily: 'monospace',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
padding: '0 3px',
|
||||
borderRadius: 3,
|
||||
fontSize: '0.82rem',
|
||||
},
|
||||
settingsSection: {},
|
||||
sectionTitle: {
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 700,
|
||||
margin: '0 0 12px',
|
||||
color: 'var(--brand-warm-white, #f0ead8)',
|
||||
},
|
||||
}
|
||||
+215
-1043
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
export interface AdminQuestion {
|
||||
id: string
|
||||
submittedAt: string
|
||||
firstName: string
|
||||
email: string
|
||||
question: string
|
||||
answer: string
|
||||
answeredAt: string | null
|
||||
isApproved: boolean
|
||||
approvedAt: string | null
|
||||
topic?: string
|
||||
reference?: string
|
||||
notifyOnAnswer?: boolean
|
||||
notifiedAt?: string
|
||||
upvotes?: number
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
type Tab = 'new' | 'ready' | 'published' | 'all'
|
||||
|
||||
const hasAnswer = (q: AdminQuestion) => Boolean(q.answer?.trim())
|
||||
const statusOf = (q: AdminQuestion): Exclude<Tab, 'all'> => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready')
|
||||
|
||||
const TAB_LABELS: Record<Tab, string> = {
|
||||
new: 'Needs answer',
|
||||
ready: 'Ready to publish',
|
||||
published: 'Published',
|
||||
all: 'All',
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
async function call(url: string, method: string, body?: unknown) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await res.json().catch(() => ({})) as { question?: AdminQuestion; message?: string }
|
||||
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
|
||||
return data
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: AdminQuestion[]
|
||||
onChange: (questions: AdminQuestion[]) => void
|
||||
}
|
||||
|
||||
export function QuestionsAdmin({ questions, onChange }: Props) {
|
||||
const counts = useMemo(() => ({
|
||||
new: questions.filter(q => statusOf(q) === 'new').length,
|
||||
ready: questions.filter(q => statusOf(q) === 'ready').length,
|
||||
published: questions.filter(q => statusOf(q) === 'published').length,
|
||||
all: questions.length,
|
||||
}), [questions])
|
||||
|
||||
const [tab, setTab] = useState<Tab>(counts.new > 0 ? 'new' : 'all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Edits live in a draft for the open question until saved
|
||||
const [draft, setDraft] = useState<{ id: string; question: string; answer: string; topic: string; reference: string } | null>(null)
|
||||
|
||||
const topics = useMemo(() => {
|
||||
const tally = new Map<string, number>()
|
||||
for (const q of questions) if (q.topic?.trim()) tally.set(q.topic.trim(), (tally.get(q.topic.trim()) ?? 0) + 1)
|
||||
return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)
|
||||
}, [questions])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
return questions
|
||||
.filter(q => tab === 'all' || statusOf(q) === tab)
|
||||
.filter(q => !needle || [q.firstName, q.question, q.answer, q.topic ?? '', q.reference ?? ''].some(v => v.toLowerCase().includes(needle)))
|
||||
}, [questions, tab, search])
|
||||
|
||||
const selected = questions.find(q => q.id === selectedId) ?? null
|
||||
const current = selected && draft?.id === selected.id
|
||||
? draft
|
||||
: selected
|
||||
? { id: selected.id, question: selected.question, answer: selected.answer ?? '', topic: selected.topic ?? '', reference: selected.reference ?? '' }
|
||||
: null
|
||||
const dirty = Boolean(selected && current && (
|
||||
current.question !== selected.question || current.answer !== (selected.answer ?? '') ||
|
||||
current.topic !== (selected.topic ?? '') || current.reference !== (selected.reference ?? '')
|
||||
))
|
||||
|
||||
const open = (id: string) => {
|
||||
if (dirty && !confirm('You have unsaved changes to this question. Discard them?')) return
|
||||
setSelectedId(id)
|
||||
setDraft(null)
|
||||
setMessage('')
|
||||
}
|
||||
const edit = (patch: Partial<NonNullable<typeof draft>>) => {
|
||||
if (!selected || !current) return
|
||||
setDraft({ ...current, ...patch })
|
||||
}
|
||||
const replace = (q: AdminQuestion) => onChange(questions.map(item => (item.id === q.id ? { ...item, ...q } : item)))
|
||||
|
||||
// After publishing, move on to the next question waiting in the same queue
|
||||
const advance = (fromId: string) => {
|
||||
const list = visible.filter(q => q.id !== fromId)
|
||||
const idx = visible.findIndex(q => q.id === fromId)
|
||||
const next = list[Math.min(idx, list.length - 1)]
|
||||
setSelectedId(next?.id ?? '')
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const run = async (label: string, fn: () => Promise<void>) => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : `Could not ${label}.`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFields = () => run('save', async () => {
|
||||
if (!selected || !current) return
|
||||
const d = await call(`/api/admin-questions/${selected.id}`, 'PATCH', current)
|
||||
if (d.question) replace(d.question)
|
||||
setDraft(null)
|
||||
setMessage('Saved.')
|
||||
})
|
||||
|
||||
const publish = () => run('publish', async () => {
|
||||
if (!selected || !current) return
|
||||
if (current.question !== selected.question) await call(`/api/admin-questions/${selected.id}`, 'PATCH', { question: current.question })
|
||||
const d = await call(`/api/admin-questions/${selected.id}/publish`, 'POST', { answer: current.answer, topic: current.topic, reference: current.reference })
|
||||
if (d.question) {
|
||||
const id = selected.id
|
||||
replace(d.question)
|
||||
advance(tab === 'published' || tab === 'all' ? '' : id)
|
||||
setMessage(selected.notifyOnAnswer && selected.email && !selected.notifiedAt ? 'Published, and the asker was emailed.' : 'Published.')
|
||||
}
|
||||
})
|
||||
|
||||
const unpublish = () => run('unpublish', async () => {
|
||||
if (!selected) return
|
||||
const d = await call(`/api/admin-questions/${selected.id}/approve`, 'POST', { approved: false })
|
||||
if (d.question) replace(d.question)
|
||||
setMessage('Taken off the site.')
|
||||
})
|
||||
|
||||
const togglePin = () => run('pin', async () => {
|
||||
if (!selected) return
|
||||
const d = await call(`/api/admin-questions/${selected.id}/pin`, selected.pinned ? 'DELETE' : 'POST')
|
||||
if (d.question) replace(d.question)
|
||||
})
|
||||
|
||||
const remove = (ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
if (!confirm(`Delete ${ids.length === 1 ? 'this question' : `${ids.length} questions`} permanently?`)) return
|
||||
return run('delete', async () => {
|
||||
const gone: string[] = []
|
||||
for (const id of ids) {
|
||||
try { await call(`/api/admin-questions/${id}`, 'DELETE'); gone.push(id) } catch { /* keep going */ }
|
||||
}
|
||||
onChange(questions.filter(q => !gone.includes(q.id)))
|
||||
setChecked(new Set())
|
||||
if (gone.includes(selectedId)) { setSelectedId(''); setDraft(null) }
|
||||
if (gone.length < ids.length) setMessage(`Deleted ${gone.length} of ${ids.length}.`)
|
||||
})
|
||||
}
|
||||
|
||||
const [manual, setManual] = useState({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
|
||||
const addManual = () => run('add the question', async () => {
|
||||
const d = await call('/api/admin-questions', 'POST', { ...manual, approve: manual.publish && Boolean(manual.answer.trim()) })
|
||||
if (d.question) {
|
||||
onChange([d.question, ...questions])
|
||||
setSelectedId(d.question.id)
|
||||
setDraft(null)
|
||||
}
|
||||
setManual({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
|
||||
setAdding(false)
|
||||
setMessage('Question added.')
|
||||
})
|
||||
|
||||
const status = selected ? statusOf(selected) : null
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Questions and answers">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Bible Questions & Answers</h2>
|
||||
<p>Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&A page right away. Pinned questions show first there and are featured on the home page.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12, alignItems: 'center' }}>
|
||||
{(Object.keys(TAB_LABELS) as Tab[]).map(t => (
|
||||
<button key={t} type="button" className={tab === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => setTab(t)}>
|
||||
{TAB_LABELS[t]} ({counts[t]})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" style={{ marginLeft: 'auto' }} onClick={() => setAdding(a => !a)}>
|
||||
{adding ? 'Cancel' : '+ Add question'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && <p className="admin-stats-note" role="status">{message}</p>}
|
||||
|
||||
{adding && (
|
||||
<div style={{ border: '1px solid var(--color-border, rgba(255,255,255,0.14))', borderRadius: 6, padding: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ marginTop: 0 }}>Add a question</h3>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||
<label htmlFor="qa-new-name">First name</label>
|
||||
<input id="qa-new-name" type="text" value={manual.firstName} onChange={e => setManual({ ...manual, firstName: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 200 }}>
|
||||
<label htmlFor="qa-new-email">Email (optional)</label>
|
||||
<input id="qa-new-email" type="email" value={manual.email} onChange={e => setManual({ ...manual, email: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="qa-new-question">Question</label>
|
||||
<textarea id="qa-new-question" rows={3} value={manual.question} onChange={e => setManual({ ...manual, question: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="qa-new-answer">Answer (optional)</label>
|
||||
<textarea id="qa-new-answer" rows={4} value={manual.answer} onChange={e => setManual({ ...manual, answer: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||
<label htmlFor="qa-new-topic">Topic</label>
|
||||
<input id="qa-new-topic" type="text" list="qa-topic-options" value={manual.topic} onChange={e => setManual({ ...manual, topic: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||
<label htmlFor="qa-new-ref">Scripture reference</label>
|
||||
<input id="qa-new-ref" type="text" placeholder="e.g. Titus 1:5-9" value={manual.reference} onChange={e => setManual({ ...manual, reference: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'block', margin: '8px 0' }}>
|
||||
<input type="checkbox" checked={manual.publish} onChange={e => setManual({ ...manual, publish: e.target.checked })} /> Publish right away (needs an answer)
|
||||
</label>
|
||||
<button type="button" className="btn-admin-save" onClick={addManual} disabled={busy || !manual.firstName.trim() || manual.question.trim().length < 5}>Add question</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<datalist id="qa-topic-options">{topics.map(t => <option key={t} value={t} />)}</datalist>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 340px) 1fr', gap: 20, alignItems: 'start' }}>
|
||||
<div>
|
||||
<div className="admin-field" style={{ marginBottom: 8 }}>
|
||||
<input type="search" placeholder="Search questions, answers, topics…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search questions" />
|
||||
</div>
|
||||
{checked.size > 0 && (
|
||||
<div className="admin-bulk-toolbar" style={{ marginBottom: 8 }}>
|
||||
<span>{checked.size} selected</span>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => remove([...checked])}>Delete</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => setChecked(new Set())}>Clear</button>
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 && <p className="admin-stats-note">{tab === 'new' ? 'Nothing waiting for an answer. 🎉' : 'No questions here.'}</p>}
|
||||
{visible.map(q => (
|
||||
<div
|
||||
key={q.id}
|
||||
style={{
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', marginBottom: 6, borderRadius: 6,
|
||||
border: `1px solid ${q.id === selectedId ? '#8a6d2f' : 'var(--color-border, rgba(255,255,255,0.14))'}`,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select question from ${q.firstName}`}
|
||||
checked={checked.has(q.id)}
|
||||
onChange={e => setChecked(prev => { const next = new Set(prev); if (e.target.checked) next.add(q.id); else next.delete(q.id); return next })}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(q.id)}
|
||||
style={{ flex: 1, textAlign: 'left', background: 'transparent', border: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }}
|
||||
>
|
||||
<strong>{q.pinned ? '📌 ' : ''}{q.firstName || 'Someone'}</strong>
|
||||
<span style={{ fontSize: '0.76rem', opacity: 0.65 }}> · {formatDate(q.submittedAt)}</span>
|
||||
<div style={{ fontSize: '0.84rem', opacity: 0.85, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{q.question}</div>
|
||||
<div style={{ fontSize: '0.74rem', opacity: 0.7, marginTop: 2 }}>
|
||||
{tab === 'all' && <>{TAB_LABELS[statusOf(q)]} · </>}
|
||||
{q.topic ? `${q.topic} · ` : ''}{q.notifyOnAnswer && !q.notifiedAt && !q.isApproved ? '✉ wants an email · ' : ''}{(q.upvotes ?? 0) > 0 ? `▲ ${q.upvotes}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{!selected || !current ? (
|
||||
<p className="admin-stats-note">Select a question to answer it.</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '0.86rem' }}>
|
||||
From <strong>{selected.firstName || 'Someone'}</strong>
|
||||
{selected.email && <> · <a href={`mailto:${selected.email}`}>{selected.email}</a></>}
|
||||
{' '}· {formatDate(selected.submittedAt)} · <em>{TAB_LABELS[status!]}</em>
|
||||
</p>
|
||||
{selected.notifyOnAnswer && selected.email && (
|
||||
<p className="admin-stats-note" style={{ marginTop: 0 }}>
|
||||
{selected.notifiedAt ? `They were emailed on ${formatDate(selected.notifiedAt)}.` : 'They asked to be emailed when this is answered. Publishing sends it.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="qa-edit-question">Question</label>
|
||||
<textarea id="qa-edit-question" rows={3} value={current.question} onChange={e => edit({ question: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="qa-edit-answer">Answer</label>
|
||||
<textarea id="qa-edit-answer" rows={9} value={current.answer} onChange={e => edit({ answer: e.target.value })} />
|
||||
<p className="admin-field-help">{current.answer.length} / 5000</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label htmlFor="qa-edit-topic">Topic</label>
|
||||
<input id="qa-edit-topic" type="text" list="qa-topic-options" value={current.topic} onChange={e => edit({ topic: e.target.value })} placeholder="e.g. Salvation" />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
|
||||
<label htmlFor="qa-edit-ref">Scripture reference</label>
|
||||
<input id="qa-edit-ref" type="text" value={current.reference} onChange={e => edit({ reference: e.target.value })} placeholder="e.g. Titus 1:5-9" />
|
||||
</div>
|
||||
</div>
|
||||
{topics.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 12px' }}>
|
||||
{topics.slice(0, 8).map(t => (
|
||||
<button key={t} type="button" className={current.topic === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => edit({ topic: t })}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{status === 'published' ? (
|
||||
<>
|
||||
<button type="button" className="btn-admin-save" onClick={saveFields} disabled={busy || !dirty}>Save changes</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={unpublish} disabled={busy}>Unpublish</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn-admin-save" onClick={publish} disabled={busy || !current.answer.trim()}>Save & publish</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={saveFields} disabled={busy || !dirty}>Save draft</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn-admin-ghost" onClick={togglePin} disabled={busy} title="Pinned questions show first on the Q&A page and are featured on the home page">{selected.pinned ? 'Unpin' : 'Pin & feature'}</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => remove([selected.id])} disabled={busy}>Delete</button>
|
||||
{dirty && <span className="admin-stats-note">Unsaved changes</span>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent } from '../content'
|
||||
import { StoreLink } from './StoreLink'
|
||||
|
||||
type TabKey = 'downloads' | 'books' | 'recommended'
|
||||
|
||||
interface Props {
|
||||
content: SiteContent
|
||||
downloadPath: (resourceId: string) => string
|
||||
// Shown when there are no downloads, books or recommended resources at all
|
||||
fallback: ReactNode
|
||||
}
|
||||
|
||||
// A description that stays short in the grid; long ones get a Read more toggle
|
||||
function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const long = text.length > threshold
|
||||
return (
|
||||
<>
|
||||
<p
|
||||
className={`${className}${long && !open ? ' res-clamped' : ''}`}
|
||||
style={long && !open ? { WebkitLineClamp: lines } : undefined}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{long && (
|
||||
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||
{open ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
|
||||
const links = content.customLinks ?? []
|
||||
// Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin)
|
||||
const gated = content.gateStoreLinks !== false
|
||||
const resources = links.filter(l => l.placement === 'resources')
|
||||
const books = links.filter(l => l.placement === 'books')
|
||||
const recommended = links.filter(l => l.placement === 'recommended')
|
||||
|
||||
const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = []
|
||||
if (content.studyGuideTitle) {
|
||||
featured.push({
|
||||
key: 'primary',
|
||||
to: '/downloads/primary-guide',
|
||||
title: content.studyGuideTitle,
|
||||
description: content.studyGuideDescription ?? '',
|
||||
image: content.seriesImageUrl || '/images/titus-cover.png',
|
||||
alt: content.seriesTitle,
|
||||
})
|
||||
}
|
||||
if (content.secondSeries?.guide?.title) {
|
||||
featured.push({
|
||||
key: 'second',
|
||||
to: '/downloads/second-guide',
|
||||
title: content.secondSeries.guide.title,
|
||||
description: content.secondSeries.guide.description ?? '',
|
||||
image: content.secondSeries.imageUrl ?? '',
|
||||
alt: content.secondSeries.title,
|
||||
})
|
||||
}
|
||||
|
||||
const counts: Record<TabKey, number> = {
|
||||
downloads: featured.length + resources.length,
|
||||
books: books.length,
|
||||
recommended: recommended.length,
|
||||
}
|
||||
const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0)
|
||||
|
||||
const fromHash = (): TabKey | null => {
|
||||
const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : ''
|
||||
return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null
|
||||
}
|
||||
const [picked, setPicked] = useState<TabKey | null>(fromHash)
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
|
||||
// Links like /resources#books, and the browser's back/forward buttons, switch the tab too
|
||||
useEffect(() => {
|
||||
const onHash = () => setPicked(fromHash())
|
||||
window.addEventListener('hashchange', onHash)
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
}, [])
|
||||
|
||||
if (available.length === 0) return <>{fallback}</>
|
||||
|
||||
const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0]
|
||||
const choose = (k: TabKey) => {
|
||||
setPicked(k)
|
||||
try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
||||
const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag))
|
||||
const labels: Record<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
|
||||
|
||||
return (
|
||||
<section className="res-hub" aria-label="Resources">
|
||||
<div className="section-inner">
|
||||
{available.length > 1 && (
|
||||
<div className="res-tabs" role="tablist" aria-label="Resource types">
|
||||
{available.map(k => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`res-tab-${k}`}
|
||||
aria-selected={tab === k}
|
||||
aria-controls="res-panel"
|
||||
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
|
||||
onClick={() => choose(k)}
|
||||
>
|
||||
{labels[k]} <span>{counts[k]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
|
||||
{tab === 'downloads' && (
|
||||
<>
|
||||
{featured.length > 0 && (
|
||||
<div className="res-featured">
|
||||
{featured.map(f => (
|
||||
<Link key={f.key} to={f.to} className="res-feature">
|
||||
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
|
||||
<div className="res-feature-body">
|
||||
<span className="res-badge">Featured</span>
|
||||
<span className="res-title">{f.title}</span>
|
||||
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
|
||||
<span className="res-action">Download free guide →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="res-tags" role="group" aria-label="Filter by topic">
|
||||
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
|
||||
{tags.map(t => (
|
||||
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownResources.length > 0 ? (
|
||||
<div className="res-grid">
|
||||
{shownResources.map(r => (
|
||||
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
|
||||
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
|
||||
<div className="res-card-body">
|
||||
<span className="res-title">{r.label}</span>
|
||||
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
|
||||
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
|
||||
<span className="res-action">Open download →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
resources.length > 0 && <p className="res-empty">No downloads tagged "{activeTag}".</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'books' && (
|
||||
<div className="res-grid">
|
||||
{books.map(book => (
|
||||
<div key={book.id} className="res-book">
|
||||
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
|
||||
<div className="res-book-body">
|
||||
<span className="res-title">{book.label}</span>
|
||||
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
|
||||
<div className="res-book-actions">
|
||||
{(book.amazonUrl || book.url) && (
|
||||
<StoreLink
|
||||
source={`book:${book.id}`}
|
||||
store="amazon"
|
||||
href={book.amazonUrl || book.url}
|
||||
gated={gated}
|
||||
className="btn-primary res-btn"
|
||||
>
|
||||
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
||||
</StoreLink>
|
||||
)}
|
||||
{book.appleBooksUrl && (
|
||||
<StoreLink
|
||||
source={`book:${book.id}`}
|
||||
store="apple"
|
||||
href={book.appleBooksUrl}
|
||||
gated={gated}
|
||||
className="btn-secondary res-btn"
|
||||
>
|
||||
{book.appleBooksLabel || 'Apple Books'}
|
||||
</StoreLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'recommended' && (
|
||||
<div className="res-grid">
|
||||
{recommended.map(item => (
|
||||
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
|
||||
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
|
||||
<div className="res-card-body">
|
||||
<span className="res-title">{item.label}</span>
|
||||
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
|
||||
<span className="res-action">Visit →</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const SAVED_KEY = 'vbn-store-link-contact'
|
||||
|
||||
interface Saved { firstName: string; lastName: string; email: string }
|
||||
|
||||
function readSaved(): Saved {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVED_KEY)
|
||||
if (raw) {
|
||||
const d = JSON.parse(raw) as Partial<Saved>
|
||||
return { firstName: d.firstName ?? '', lastName: d.lastName ?? '', email: d.email ?? '' }
|
||||
}
|
||||
} catch { /* private mode or bad data */ }
|
||||
return { firstName: '', lastName: '', email: '' }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// Which guide or book this link belongs to (the server finds the real address from this)
|
||||
source: string
|
||||
store: 'amazon' | 'apple'
|
||||
// The address used when the sign-up step is switched off
|
||||
href: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
gated: boolean
|
||||
}
|
||||
|
||||
// A button that asks for name and email, then sends the visitor on to Amazon or Apple Books.
|
||||
// With the gate switched off it is just a normal link.
|
||||
export function StoreLink({ source, store, href, className, children, gated }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [fields, setFields] = useState<Saved>({ firstName: '', lastName: '', email: '' })
|
||||
const [subscribe, setSubscribe] = useState(true)
|
||||
const [honey, setHoney] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'error'>('idle')
|
||||
const [error, setError] = useState('')
|
||||
const firstRef = useRef<HTMLInputElement>(null)
|
||||
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
const t = window.setTimeout(() => firstRef.current?.focus(), 30)
|
||||
return () => { document.removeEventListener('keydown', onKey); window.clearTimeout(t) }
|
||||
}, [open])
|
||||
|
||||
if (!gated) {
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer" className={className}>{children}</a>
|
||||
}
|
||||
|
||||
const openDialog = () => {
|
||||
setFields(readSaved())
|
||||
setStatus('idle')
|
||||
setError('')
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setStatus('sending')
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/store-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source, store, ...fields, subscribe, _honey: honey }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string }
|
||||
if (!res.ok || !data.url) {
|
||||
setError(data.message ?? 'Something went wrong. Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
try { localStorage.setItem(SAVED_KEY, JSON.stringify(fields)) } catch { /* ignore */ }
|
||||
window.location.assign(data.url)
|
||||
} catch {
|
||||
setError('Could not connect. Please try again later.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const set = (name: keyof Saved) => (e: React.ChangeEvent<HTMLInputElement>) => setFields(f => ({ ...f, [name]: e.target.value }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={className} onClick={openDialog}>{children}</button>
|
||||
{open && (
|
||||
<div className="sl-overlay" onMouseDown={e => { if (e.target === e.currentTarget) setOpen(false) }}>
|
||||
<div className="sl-dialog" role="dialog" aria-modal="true" aria-labelledby={`sl-title-${source}-${store}`}>
|
||||
<h3 id={`sl-title-${source}-${store}`}>One quick step</h3>
|
||||
<p className="sl-lead">Tell us where to send updates, then we'll take you straight to {storeName}.</p>
|
||||
<form onSubmit={submit} noValidate>
|
||||
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
|
||||
<div className="sl-row">
|
||||
<label>
|
||||
First name
|
||||
<input ref={firstRef} type="text" required autoComplete="given-name" value={fields.firstName} onChange={set('firstName')} />
|
||||
</label>
|
||||
<label>
|
||||
Last name
|
||||
<input type="text" required autoComplete="family-name" value={fields.lastName} onChange={set('lastName')} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Email
|
||||
<input type="email" required autoComplete="email" value={fields.email} onChange={set('email')} />
|
||||
</label>
|
||||
<label className="sl-check">
|
||||
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
|
||||
<span>Subscribe me to updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||
</label>
|
||||
{status === 'error' && <p className="sl-error" role="alert">{error}</p>}
|
||||
<div className="sl-actions">
|
||||
<button type="submit" className="btn-primary" disabled={status === 'sending'}>
|
||||
{status === 'sending' ? 'One moment…' : `Continue to ${storeName}`}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user