Compare commits

...
13 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 11f3d76e9b Fix Nuggets workflow audio: serve with audio content type (nosniff blocked octet-stream), show playback errors; v2.17.1
Publish Container / build (push) Successful in 16m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:56:41 -04:00
nmemmertandClaude Sonnet 5.5 326838e5bc Podcast workflow: episodes start manually via Start new episode (name, season, ep #); scripts no longer create entries; v2.17.0
Publish Container / build (push) Successful in 16m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:33:23 -04:00
nmemmertandClaude Sonnet 5.5 3a69e03e60 Podcast workflow: per-lane season number set on the page, only that season shown; v2.16.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:28:04 -04:00
nmemmertandClaude Sonnet 5.5 900a3efee3 Podcast workflow: assign lanes by hand, match by season, unique keys for unseasoned episodes; drop old checklist data file; v2.16.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:25:00 -04:00
nmemmertandClaude Sonnet 5.5 ac1b06f945 Fix admin dashboard crash after removing upcoming events (drop Upcoming card); v2.15.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:21:37 -04:00
nmemmertandClaude Sonnet 5.5 28fc822132 Podcast workflow steps: read script, record, edit, upload; v2.15.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:18:51 -04:00
nmemmertandClaude Sonnet 5.5 f181cdb182 Add Podcast Episode Workflow (Lane A / Lane B) with Nuggets steps; released dates from the feed; v2.15.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:16:32 -04:00
nmemmertandClaude Sonnet 5.5 5c2f9113b7 Remove unused calendar styles; v2.14.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:12:22 -04:00
nmemmertandClaude Sonnet 5.5 13d0eb6651 Remove podcast checklist backend, calendar events and reminder emails; v2.14.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:11:09 -04:00
nmemmertandClaude Sonnet 5.5 99b19faede Remove Lane A/B production checklist panel from admin; v2.14.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:09:28 -04:00
nmemmertandClaude Sonnet 5.5 66b7bbef52 Remove standalone podcast-checklist app; admin checklist now uses the existing checklist backend; v2.14.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:06:32 -04:00
nmemmertandClaude Sonnet 5.5 d6d2c087b2 Add Podcast Production Checklist (Lane A / Lane B) to admin; v2.14.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:04:10 -04:00
nmemmertandClaude Sonnet 5.5 ffb3551b7e Add Nuggets Episode Workflow to admin (listen, cold open, record, edit, Spotify); v2.13.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 07:59:04 -04:00
24 changed files with 830 additions and 2308 deletions

No files matched your search

+4
View File
@@ -55,3 +55,7 @@ data/study-community.json
data/analytics-events.json data/analytics-events.json
data/admin-reply-history.json data/admin-reply-history.json
data/episode-plays.json data/episode-plays.json
# Episode workflow progress — lives only in the server data volume
data/podcast-workflow.json
data/nuggets-workflow.json
-1
View File
@@ -141,7 +141,6 @@ siteforge/
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission | | `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management | | `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script | | `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
| `GET /api/admin-subscribers` | Subscriber list | | `GET /api/admin-subscribers` | Subscriber list |
--- ---
-681
View File
@@ -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"
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.12.0", "version": "2.17.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.12.0", "version": "2.17.1",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.12.0", "version": "2.17.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
-18
View File
@@ -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"]
-63
View File
@@ -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.
-12
View File
@@ -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"
}
}
-401
View File
@@ -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>
-43
View File
@@ -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}`));
+4 -10
View File
@@ -21,10 +21,8 @@ import {
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk, loadQrCodesFromDisk,
loadEpisodePlaysFromDisk, loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk, loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk, loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk,
loadAuditLogFromDisk, loadAuditLogFromDisk,
loadWebhooksFromDisk, loadWebhooksFromDisk,
loadStudySessionsFromDisk, loadStudySessionsFromDisk,
@@ -60,8 +58,6 @@ import { register as registerStudyComments } from './server/routes/study-comment
import { register as registerStudyCertificate } from './server/routes/study-certificate.js' import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js' import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.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 registerInboundEmail } from './server/routes/inbound-email.js'
import { register as registerQuestions } from './server/routes/questions.js' import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js' import { register as registerAnalytics } from './server/routes/analytics.js'
@@ -69,6 +65,8 @@ import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.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 { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js' import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js' import { register as registerQrCodes } from './server/routes/qr-codes.js'
@@ -118,7 +116,6 @@ registerStudyComments(app)
registerStudyCertificate(app) registerStudyCertificate(app)
registerEpisodeScripts(app) registerEpisodeScripts(app)
registerContact(app) registerContact(app)
registerCalendarEvents(app)
registerInboundEmail(app) registerInboundEmail(app)
registerQuestions(app) registerQuestions(app)
registerAnalytics(app) registerAnalytics(app)
@@ -126,6 +123,8 @@ registerDownloads(app)
registerEpisodes(app) registerEpisodes(app)
registerNuggets(app) registerNuggets(app)
registerNuggetsHostUpload(app) registerNuggetsHostUpload(app)
registerNuggetsWorkflow(app)
registerPodcastWorkflow(app)
registerFeed(app) registerFeed(app)
registerQrCodes(app) registerQrCodes(app)
registerBiblePassage(app) registerBiblePassage(app)
@@ -183,10 +182,8 @@ Promise.all([
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(), loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(), loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(), loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(), loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(), loadAuditLogFromDisk(),
loadWebhooksFromDisk(), loadWebhooksFromDisk(),
loadStudySessionsFromDisk(), loadStudySessionsFromDisk(),
@@ -207,7 +204,6 @@ Promise.all([
createBackupSnapshot('scheduled').catch(() => {}) createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS) }, BACKUP_INTERVAL_MS)
startReminderScheduler()
startNuggetsSeafileWatcher() startNuggetsSeafileWatcher()
// Purge expired study sessions every hour // Purge expired study sessions every hour
@@ -279,7 +275,6 @@ Promise.all([
state.draftQuestionsWritePromise, state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise, state.replyTemplatesWritePromise,
state.replyHistoryWritePromise, state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise, state.studyUsersWritePromise,
state.studyCommunityWritePromise, state.studyCommunityWritePromise,
state.studyRemindersWritePromise, state.studyRemindersWritePromise,
@@ -291,7 +286,6 @@ Promise.all([
state.analyticsEventsWritePromise, state.analyticsEventsWritePromise,
state.qrCodesWritePromise, state.qrCodesWritePromise,
state.emailSettingsWritePromise, state.emailSettingsWritePromise,
state.calendarEventsWritePromise,
state.auditLogWritePromise, state.auditLogWritePromise,
state.webhooksWritePromise, state.webhooksWritePromise,
state.studySessionsWritePromise, state.studySessionsWritePromise,
-40
View File
@@ -47,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 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_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 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 BACKUP_DIR = path.join(DATA_DIR, 'backups')
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -70,7 +69,6 @@ export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' 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 SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json') 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 AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.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 STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
@@ -164,18 +162,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 = { export const EMPTY_HIT_STATS = {
totalHits: 0, totalHits: 0,
realHits: 0, realHits: 0,
@@ -203,29 +189,3 @@ export const EMPTY_VISITOR_STATS = {
geoCacheByIp: {}, 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],
}
}
-133
View File
@@ -26,7 +26,6 @@ import {
QR_CODES_FILE, QR_CODES_FILE,
REPLY_TEMPLATES_FILE, REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE, REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE,
BACKUP_DIR, BACKUP_DIR,
UPLOADS_DIR, UPLOADS_DIR,
UPLOADS_META_FILE, UPLOADS_META_FILE,
@@ -34,7 +33,6 @@ import {
EPISODE_PLAYS_FILE, EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE, ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE, EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE, AUDIT_LOG_FILE,
WEBHOOKS_FILE, WEBHOOKS_FILE,
STUDY_SESSIONS_FILE, STUDY_SESSIONS_FILE,
@@ -51,8 +49,6 @@ import {
MAX_STUDY_NOTES_PER_USER, MAX_STUDY_NOTES_PER_USER,
MAX_STUDY_NOTE_LENGTH, MAX_STUDY_NOTE_LENGTH,
MAX_STUDY_COMMENTS, MAX_STUDY_COMMENTS,
DEFAULT_PODCAST_CHECKLIST_TASKS,
buildDefaultPodcastChecklist,
} from './config.js' } from './config.js'
import { state } from './state.js' import { state } from './state.js'
@@ -424,36 +420,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 ───────────────────────────────────────────────────────────── // ── Audit log ─────────────────────────────────────────────────────────────
export function appendAuditEntry(action, details) { export function appendAuditEntry(action, details) {
@@ -552,32 +518,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 ──────────────────────────────────────────────────────────── // ── Study users ────────────────────────────────────────────────────────────
export function queueStudyUsersWrite() { export function queueStudyUsersWrite() {
@@ -1074,7 +1014,6 @@ export async function createBackupSnapshot(reason = 'scheduled') {
reason, reason,
adminContent: null, adminContent: null,
draftContent: null, draftContent: null,
podcastChecklist: state.podcastChecklist,
publishState: state.publishState, publishState: state.publishState,
hitStats: state.hitStats, hitStats: state.hitStats,
visitorStats: state.visitorStats, visitorStats: state.visitorStats,
@@ -1200,7 +1139,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory) state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite() queueHitStatsWrite()
queueVisitorStatsWrite() queueVisitorStatsWrite()
@@ -1209,7 +1147,6 @@ export async function restoreFromBackup(filename) {
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
queueReplyTemplatesWrite() queueReplyTemplatesWrite()
queueReplyHistoryWrite() queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([ await Promise.all([
state.hitStatsWritePromise, state.hitStatsWritePromise,
@@ -1219,7 +1156,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissionsWritePromise, state.contactSubmissionsWritePromise,
state.replyTemplatesWritePromise, state.replyTemplatesWritePromise,
state.replyHistoryWritePromise, state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
]) ])
await refreshContentCaches() await refreshContentCaches()
await createBackupSnapshot('post-restore') await createBackupSnapshot('post-restore')
@@ -1393,75 +1329,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) { function normalizeStudyUsername(value) {
if (typeof value !== 'string') return '' if (typeof value !== 'string') return ''
return value.trim().toLowerCase() return value.trim().toLowerCase()
-111
View File
@@ -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
}
}
-3
View File
@@ -24,7 +24,6 @@ import {
loadDownloadCountsFromDisk, loadDownloadCountsFromDisk,
loadQrCodesFromDisk, loadQrCodesFromDisk,
loadEpisodePlaysFromDisk, loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk, loadAnalyticsEventsFromDisk,
createBackupSnapshot, createBackupSnapshot,
refreshContentCaches, refreshContentCaches,
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
state.draftQuestionsWritePromise, state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise, state.replyTemplatesWritePromise,
state.replyHistoryWritePromise, state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise, state.studyUsersWritePromise,
state.studyCommunityWritePromise, state.studyCommunityWritePromise,
state.studyRemindersWritePromise, state.studyRemindersWritePromise,
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
loadDownloadCountsFromDisk(), loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(), loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(), loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(), loadAnalyticsEventsFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
-17
View File
@@ -26,13 +26,11 @@ import {
queueContactSubmissionsWrite, queueContactSubmissionsWrite,
queueReplyTemplatesWrite, queueReplyTemplatesWrite,
queueReplyHistoryWrite, queueReplyHistoryWrite,
queuePodcastChecklistWrite,
queueWebhooksWrite, queueWebhooksWrite,
createBackupSnapshot, createBackupSnapshot,
listBackupPreviews, listBackupPreviews,
readBackupPreview, readBackupPreview,
restoreFromBackup, restoreFromBackup,
sanitizePodcastChecklist,
} from '../data.js' } from '../data.js'
import { import {
pruneStatsByDays, pruneStatsByDays,
@@ -278,21 +276,6 @@ export function register(app) {
res.json(status) 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) => { app.get('/api/site-config', async (_req, res) => {
try { try {
const parsed = await loadSiteContentFile(DATA_FILE) const parsed = await loadSiteContentFile(DATA_FILE)
-19
View File
@@ -146,24 +146,6 @@ async function buildLatestEpisode() {
} }
} }
function buildUpcomingEvents() {
const today = dayKey(0)
const events = (state.calendarEvents ?? [])
.filter(ev => ev.date && ev.date >= today && !ev.completed)
.map(ev => ({ id: ev.id, kind: 'event', type: ev.type, title: ev.title, date: ev.date, startTime: ev.startTime ?? null }))
const episodes = (state.podcastChecklist?.episodes ?? [])
.filter(ep => ep.datePublished && ep.datePublished >= today)
.map(ep => ({
id: `ep-${ep.episodeNumber ?? ep.title}`,
kind: 'episode',
type: 'episode',
title: `${ep.episodeNumber ? `Ep. ${ep.episodeNumber}: ` : ''}${ep.title || 'Untitled'}`,
date: ep.datePublished,
startTime: null,
}))
return [...events, ...episodes].sort((a, b) => a.date.localeCompare(b.date)).slice(0, 3)
}
async function buildNuggets() { async function buildNuggets() {
const { entries } = await loadHistory() const { entries } = await loadHistory()
const weekAgo = Date.now() - 7 * DAY_MS const weekAgo = Date.now() - 7 * DAY_MS
@@ -243,7 +225,6 @@ export function register(app) {
reengagement: buildReengagement(), reengagement: buildReengagement(),
studyProgress, studyProgress,
latestEpisode, latestEpisode,
upcoming: buildUpcomingEvents(),
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest }, nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
health, health,
lastBroadcast, lastBroadcast,
-192
View File
@@ -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 })
})
}
+145
View File
@@ -0,0 +1,145 @@
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']
async function loadWorkflow() {
try {
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
} catch {
return { items: {} }
}
}
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)
}
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 ?? '')
if (!res.headersSent) res.status(500).json({ message: 'Audio stream failed.' })
}
})
}
+189
View File
@@ -0,0 +1,189 @@
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']
async function load() {
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.
function laneSeasonOf(lane, wf) {
const saved = wf.laneSeasons?.[lane]
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
const content = state.cachedSiteContent ?? {}
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 load()
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 load()
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 load()
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 load()
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 load()
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 load()
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 })
})
}
-5
View File
@@ -3,7 +3,6 @@ import {
EMPTY_VISITOR_STATS, EMPTY_VISITOR_STATS,
DEFAULT_PUBLISH_STATE, DEFAULT_PUBLISH_STATE,
DEFAULT_REPLY_TEMPLATES, DEFAULT_REPLY_TEMPLATES,
buildDefaultPodcastChecklist,
} from './config.js' } from './config.js'
export const state = { export const state = {
@@ -36,8 +35,6 @@ export const state = {
replyHistory: [], replyHistory: [],
replyHistoryWritePromise: Promise.resolve(), replyHistoryWritePromise: Promise.resolve(),
podcastChecklist: buildDefaultPodcastChecklist(),
podcastChecklistWritePromise: Promise.resolve(),
studyUsers: [], studyUsers: [],
studyUsersWritePromise: Promise.resolve(), studyUsersWritePromise: Promise.resolve(),
@@ -100,8 +97,6 @@ export const state = {
newsletterIssues: [], newsletterIssues: [],
newsletterIssuesWritePromise: Promise.resolve(), newsletterIssuesWritePromise: Promise.resolve(),
calendarEvents: [],
calendarEventsWritePromise: Promise.resolve(),
auditLog: [], auditLog: [],
auditLogWritePromise: Promise.resolve(), auditLogWritePromise: Promise.resolve(),
+485 -2
View File
@@ -730,8 +730,8 @@ type AdminView =
| 'start-here' | 'start-here'
| 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy' | 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox' | 'inbox'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
@@ -782,6 +782,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' }, { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
{ kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' }, { kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' },
{ kind: 'leaf', view: 'podcast:workflow', label: 'Episode Workflow' },
{ kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' }, { kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
]}, ]},
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [ { kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
@@ -790,6 +791,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' }, { kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' }, { kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' }, { kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' }, { kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
]}, ]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' }, { kind: 'leaf', view: 'colossians-study', label: 'Studies' },
@@ -1615,6 +1617,485 @@ function NuggetsHistoryAdmin() {
) )
} }
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
interface WorkflowState {
steps: Record<WorkflowStepKey, string | null>
coldOpenText: string
notes: string
spotifyUrl: string
}
interface WorkflowItem {
id: string
entry: { hostName: string; episodeName: string; passage: string; description?: string; filename: string; uploadedAt: string }
state: WorkflowState
}
const WORKFLOW_STEP_DEFS: { key: WorkflowStepKey; label: string; hint: string }[] = [
{ key: 'listen', label: 'Listen to the uploaded file', hint: 'Audition the host’s recording and jot down notes.' },
{ key: 'coldOpen', label: 'Get a cold open', hint: 'Pick or write the hook that opens the episode.' },
{ key: 'recordColdOpen', label: 'Record the cold open', hint: 'Record the cold open take.' },
{ key: 'edit', label: 'Edit the episode', hint: 'Cold open + episode: trim, clean up, mix, master.' },
{ key: 'spotify', label: 'Upload to Spotify', hint: 'Publish with show notes and paste the episode link below.' },
]
function NuggetsWorkflowAdmin() {
const [items, setItems] = useState<WorkflowItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState('')
const [showDone, setShowDone] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-workflow')
.then(r => r.json())
.then(d => setItems(d.items ?? []))
.catch(() => setError('Could not load uploads.'))
.finally(() => setLoading(false))
}, [])
const isDone = (i: WorkflowItem) => WORKFLOW_STEP_DEFS.every(s => i.state.steps[s.key])
const visible = showDone ? items : items.filter(i => !isDone(i))
const selected = items.find(i => i.id === selectedId) ?? null
const save = async (id: string, patch: Partial<Omit<WorkflowState, 'steps'>> & { steps?: Partial<Record<WorkflowStepKey, boolean>> }) => {
setSaving(true)
setError('')
try {
const r = await fetch(`/api/admin/nuggets-workflow/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
const d = await r.json()
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: d.state } : i)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed.')
} finally {
setSaving(false)
}
}
const patchLocal = (id: string, patch: Partial<WorkflowState>) =>
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: { ...i.state, ...patch } } : i)))
return (
<section className="admin-panel-section" aria-label="Nuggets episode workflow">
<div className="admin-panel-head">
<h2>Nuggets — Episode Workflow</h2>
<p>Walk each portal upload through: listen → cold open → record cold open → edit → Spotify.</p>
</div>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{loading ? <p className="admin-stats-note">Loading…</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 8 }}>
<input type="checkbox" checked={showDone} onChange={e => setShowDone(e.target.checked)} /> Show completed
</label>
{visible.length === 0 && <p className="admin-stats-note">{items.length === 0 ? 'No uploads yet.' : 'All caught up.'}</p>}
{visible.map(i => {
const done = WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length
return (
<button
key={i.id}
type="button"
onClick={() => setSelectedId(i.id)}
style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
border: `1px solid ${i.id === selectedId ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit',
}}
>
<strong>{i.entry.episodeName}</strong>
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
{i.entry.passage} · {i.entry.hostName} · {new Date(i.entry.uploadedAt).toLocaleDateString()}
</div>
<div style={{ fontSize: '0.78rem', marginTop: 2 }}>{done}/{WORKFLOW_STEP_DEFS.length} steps</div>
</button>
)
})}
</div>
<div>
{!selected ? <p className="admin-stats-note">Select an upload to work on.</p> : (
<>
<h3 style={{ marginTop: 0 }}>{selected.entry.episodeName}</h3>
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
{selected.entry.passage} · uploaded by {selected.entry.hostName} · <code>{selected.entry.filename}</code>
</p>
{selected.entry.description && <p style={{ fontSize: '0.86rem' }}>{selected.entry.description}</p>}
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
{WORKFLOW_STEP_DEFS.map((s, idx) => {
const completedAt = selected.state.steps[s.key]
return (
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(completedAt)}
onChange={e => save(selected.id, { steps: { [s.key]: e.target.checked } })}
style={{ marginTop: 4 }}
/>
<span>
<strong>{idx + 1}. {s.label}</strong>
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
{s.hint}{completedAt ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
</span>
</span>
</label>
{s.key === 'listen' && (
<div style={{ marginTop: 8 }}>
<audio
key={selected.id}
controls
preload="none"
style={{ width: '100%' }}
src={`/api/admin/nuggets-workflow/${selected.id}/audio`}
onError={async () => {
const r = await fetch(`/api/admin/nuggets-workflow/${selected.id}/audio`, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!r) setError('Audio request failed (network).')
else if (r.ok) setError('The server sent the audio but the browser could not play it. The file may be in an unsupported format.')
else setError(`Audio unavailable (${r.status}): ${(await r.json().catch(() => ({}))).message ?? 'unknown error'}`)
}}
/>
</div>
)}
{s.key === 'coldOpen' && (
<textarea
rows={4}
placeholder="Cold open script / hook…"
value={selected.state.coldOpenText}
onChange={e => patchLocal(selected.id, { coldOpenText: e.target.value })}
onBlur={e => save(selected.id, { coldOpenText: e.target.value })}
style={{ width: '100%', marginTop: 8 }}
/>
)}
{s.key === 'spotify' && (
<input
type="text"
placeholder="Spotify episode URL (optional)"
value={selected.state.spotifyUrl}
onChange={e => patchLocal(selected.id, { spotifyUrl: e.target.value })}
onBlur={e => save(selected.id, { spotifyUrl: e.target.value })}
style={{ width: '100%', marginTop: 8 }}
/>
)}
</li>
)
})}
</ol>
<div className="admin-field" style={{ marginTop: 12 }}>
<label htmlFor="wf-notes">Notes</label>
<textarea
id="wf-notes"
rows={3}
value={selected.state.notes}
onChange={e => patchLocal(selected.id, { notes: e.target.value })}
onBlur={e => save(selected.id, { notes: e.target.value })}
/>
</div>
{saving && <p className="admin-stats-note">Saving…</p>}
</>
)}
</div>
</div>
)}
</section>
)
}
type PodcastStepKey = 'readScript' | 'record' | 'edit' | 'upload'
interface PodcastWorkflowState { steps: Record<PodcastStepKey, string | null>; notes: string }
const PODCAST_STEP_DEFS: { key: PodcastStepKey; label: string; hint: string }[] = [
{ key: 'readScript', label: 'Read the script', hint: 'Read it through, mark pacing and emphasis.' },
{ key: 'record', label: 'Record the episode', hint: 'Capture the final take.' },
{ key: 'edit', label: 'Edit the episode', hint: 'Trim, clean up, mix and master.' },
{ key: 'upload', label: 'Upload to Spotify', hint: 'Publish with show notes. It moves to Released once it’s in the feed.' },
]
// ── Podcast Episode Workflow (Lane A / Lane B) ─────────────────────────────
interface PodcastWorkflowItem {
key: string
season: string
episodeNumber: string
title: string
lane: 'A' | 'B' | 'other'
laneOverride?: '' | 'A' | 'B' | 'other'
released: boolean
releaseDate?: string
spotifyUrl?: string
planned?: boolean
state: PodcastWorkflowState
}
function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; laneB: { title: string } }) {
const [laneSeasons, setLaneSeasons] = useState<{ A: string; B: string }>({ A: '', B: '' })
const [items, setItems] = useState<PodcastWorkflowItem[]>([])
const [feedOk, setFeedOk] = useState(true)
const [loading, setLoading] = useState(true)
const [lane, setLane] = useState<'A' | 'B' | 'other'>('A')
const [selectedKey, setSelectedKey] = useState('')
const [showReleased, setShowReleased] = useState(false)
const [starting, setStarting] = useState(false)
const [newSeason, setNewSeason] = useState('')
const [newNumber, setNewNumber] = useState('')
const [newTitle, setNewTitle] = useState('')
const [error, setError] = useState('')
const reload = () =>
fetch('/api/admin/podcast-workflow')
.then(r => r.json())
.then(d => { setItems(d.items ?? []); setFeedOk(d.feedOk !== false); setLaneSeasons(d.laneSeasons ?? { A: '', B: '' }) })
.catch(() => setError('Could not load episodes.'))
.finally(() => setLoading(false))
useEffect(() => { reload() }, [])
const laneSeason = lane === 'A' || lane === 'B' ? laneSeasons[lane] : ''
const saveLaneSeason = async (value: string) => {
if (lane === 'other') return
setError('')
const r = await fetch('/api/admin/podcast-workflow-lane-seasons', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [lane]: value }),
})
if (!r.ok) { const d = await r.json(); setError(d.message ?? 'Could not save season.'); return }
setSelectedKey('')
reload()
}
const hasOther = items.some(i => i.lane === 'other')
const inLane = items.filter(i => i.lane === lane)
const upcoming = inLane.filter(i => !i.released)
const released = inLane.filter(i => i.released)
const selected = items.find(i => i.key === selectedKey) ?? null
const save = async (key: string, patch: Record<string, unknown>) => {
setError('')
try {
const r = await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
const d = await r.json()
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: d.state } : i)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed.')
}
}
const patchLocal = (key: string, patch: Partial<PodcastWorkflowState>) =>
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: { ...i.state, ...patch } } : i)))
const addPlanned = async () => {
setError('')
const r = await fetch('/api/admin/podcast-workflow/planned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ season: newSeason, episodeNumber: newNumber, title: newTitle }),
})
const d = await r.json()
if (!r.ok) { setError(d.message ?? 'Could not add episode.'); return }
setNewNumber(''); setNewTitle(''); setStarting(false)
await reload()
setSelectedKey(d.key)
}
const setLaneFor = async (key: string, value: string) => {
await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}/lane`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane: value }),
})
setSelectedKey('')
reload()
}
const removePlanned = async (key: string) => {
if (!confirm('Remove this planned episode and its progress?')) return
await fetch(`/api/admin/podcast-workflow/planned/${encodeURIComponent(key)}`, { method: 'DELETE' })
setSelectedKey('')
reload()
}
const label = (i: PodcastWorkflowItem) => `${i.season ? `S${i.season} ` : ''}${i.episodeNumber ? `Ep ${i.episodeNumber}` : 'Ep'}${i.title ? ` — ${i.title}` : ''}`
const doneCount = (i: PodcastWorkflowItem) => (i.released ? PODCAST_STEP_DEFS.length : PODCAST_STEP_DEFS.filter(s => i.state.steps[s.key]).length)
const row = (i: PodcastWorkflowItem) => (
<button
key={i.key}
type="button"
onClick={() => setSelectedKey(i.key)}
style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
border: `1px solid ${i.key === selectedKey ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', font: 'inherit',
}}
>
<strong>{label(i)}</strong>
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
{i.released ? `Released ${i.releaseDate || ''}` : `${doneCount(i)}/${PODCAST_STEP_DEFS.length} steps`}
</div>
</button>
)
return (
<section className="admin-panel-section" aria-label="Podcast episode workflow">
<div className="admin-panel-head">
<h2>Podcast — Episode Workflow</h2>
<p>Every episode for each series: what’s still in progress, and what’s already out (dates come from your Spotify feed).</p>
</div>
{!feedOk && <p style={{ color: '#c0392b' }}>Couldn’t reach the podcast feed, so released episodes may be missing right now.</p>}
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
{([['A', laneA.title || 'Primary series'], ['B', laneB.title || 'Second series']] as const).map(([k, t]) => (
<button key={k} type="button" className={lane === k ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane(k); setSelectedKey('') }}>
Lane {k} — {t} ({items.filter(i => i.lane === k && !i.released).length} in progress)
</button>
))}
{hasOther && (
<button type="button" className={lane === 'other' ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane('other'); setSelectedKey('') }}>
Other seasons ({items.filter(i => i.lane === 'other' && !i.released).length} in progress)
</button>
)}
</div>
{lane !== 'other' ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '0 0 12px', flexWrap: 'wrap' }}>
<label htmlFor="pw-season">Season number for Lane {lane}</label>
<input
key={`${lane}-${laneSeason}`}
id="pw-season"
type="number"
min={1}
defaultValue={laneSeason}
style={{ width: 80 }}
onBlur={e => { if (e.target.value !== laneSeason) saveLaneSeason(e.target.value) }}
/>
<span className="admin-stats-note">
{laneSeason ? `Only season ${laneSeason} episodes are shown here.` : 'Set a season number so this lane picks up its episodes, or start an episode and assign it by hand.'}
</span>
</div>
) : (
<p className="admin-stats-note">Episodes from other seasons (finished or not assigned to a lane).</p>
)}
{loading ? <p className="admin-stats-note">Loading…</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<strong style={{ fontSize: '0.82rem' }}>Not released yet</strong>
<div style={{ marginTop: 6 }}>
{upcoming.length === 0 && <p className="admin-stats-note">Nothing in progress.</p>}
{upcoming.map(row)}
</div>
{!starting ? (
<button type="button" className="btn-admin-add" style={{ margin: '8px 0 14px' }} onClick={() => { setNewSeason(laneSeason); setStarting(true) }}>
+ Start new episode
</button>
) : (
<div style={{ margin: '8px 0 14px', padding: 10, border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6 }}>
<div className="admin-field">
<label htmlFor="pw-new-title">Episode name</label>
<input id="pw-new-title" type="text" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>
<div className="admin-field" style={{ flex: 1 }}>
<label htmlFor="pw-new-season">Season</label>
<input id="pw-new-season" type="number" min={1} value={newSeason} onChange={e => setNewSeason(e.target.value)} />
</div>
<div className="admin-field" style={{ flex: 1 }}>
<label htmlFor="pw-new-number">Episode #</label>
<input id="pw-new-number" type="number" min={1} value={newNumber} onChange={e => setNewNumber(e.target.value)} />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn-admin-save" onClick={addPlanned} disabled={!newTitle.trim() || !newSeason || !newNumber}>Start workflow</button>
<button type="button" className="btn-admin-ghost" onClick={() => setStarting(false)}>Cancel</button>
</div>
</div>
)}
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
<input type="checkbox" checked={showReleased} onChange={e => setShowReleased(e.target.checked)} /> Show released ({released.length})
</label>
{showReleased && released.map(row)}
</div>
<div>
{!selected ? <p className="admin-stats-note">Select an episode to work on.</p> : (
<>
<h3 style={{ marginTop: 0 }}>{label(selected)}</h3>
{selected.released ? (
<p style={{ fontSize: '0.86rem' }}>
Released {selected.releaseDate}
{selected.spotifyUrl && <> · <a href={selected.spotifyUrl} target="_blank" rel="noopener noreferrer">Listen</a></>}
</p>
) : (
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
This moves to Released on its own once season {selected.season} episode {selected.episodeNumber} shows up in your Spotify feed.
</p>
)}
<div className="admin-field" style={{ maxWidth: 360, margin: '8px 0 12px' }}>
<label htmlFor="pw-lane">Lane</label>
<select id="pw-lane" value={selected.laneOverride || 'auto'} onChange={e => setLaneFor(selected.key, e.target.value)}>
<option value="auto">Automatic (by season number{selected.season ? ` ${selected.season}` : ''})</option>
<option value="A">Lane A — {laneA.title || 'Primary series'}</option>
<option value="B">Lane B — {laneB.title || 'Second series'}</option>
<option value="other">Other</option>
</select>
</div>
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
{PODCAST_STEP_DEFS.map((s, idx) => {
const completedAt = selected.state.steps[s.key]
const checked = selected.released || Boolean(completedAt)
return (
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: selected.released ? 'default' : 'pointer' }}>
<input
type="checkbox"
checked={checked}
disabled={selected.released}
onChange={e => save(selected.key, { steps: { [s.key]: e.target.checked } })}
style={{ marginTop: 4 }}
/>
<span>
<strong>{idx + 1}. {s.label}</strong>
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
{s.hint}{completedAt && !selected.released ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
</span>
</span>
</label>
</li>
)
})}
</ol>
<div className="admin-field" style={{ marginTop: 12 }}>
<label htmlFor="pw-notes">Notes</label>
<textarea
id="pw-notes"
rows={3}
value={selected.state.notes}
onChange={e => patchLocal(selected.key, { notes: e.target.value })}
onBlur={e => save(selected.key, { notes: e.target.value })}
/>
</div>
{selected.planned && !selected.released && (
<button type="button" className="btn-admin-remove" onClick={() => removePlanned(selected.key)}>Remove this episode</button>
)}
</>
)}
</div>
</div>
)}
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) { export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content) const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent) const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -5809,6 +6290,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)} )}
{/* EPISODE SCRIPTS */} {/* EPISODE SCRIPTS */}
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
{adminView === 'podcast:scripts' && ( {adminView === 'podcast:scripts' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
@@ -6102,6 +6584,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />} {adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />}
{/* NUGGETS UPLOAD HISTORY */} {/* NUGGETS UPLOAD HISTORY */}
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />} {adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
{/* STUDY COMMENTS */} {/* STUDY COMMENTS */}
-538
View File
@@ -13319,536 +13319,6 @@ a.study-track-card:hover {
padding: 0.5rem 1.25rem; padding: 0.5rem 1.25rem;
} }
/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
.cal-app {
display: flex;
flex-direction: column;
height: 100dvh;
background: #0f0f0f;
color: #e8e2d5;
font-family: system-ui, -apple-system, sans-serif;
overflow: hidden;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
gap: 0.75rem;
flex-wrap: wrap;
}
.cal-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cal-header-right {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.cal-month-title {
font-size: 1.1rem;
font-weight: 600;
color: #e8e2d5;
margin: 0;
min-width: 180px;
text-align: center;
}
.cal-nav-btn {
background: #2a2a2a;
border: 1px solid #363636;
color: #c0b8a8;
border-radius: 6px;
width: 32px;
height: 32px;
font-size: 1.1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
}
.cal-nav-btn:hover { background: #333; }
.cal-saving {
font-size: 0.8rem;
color: #c8860a;
margin-left: 0.25rem;
}
.cal-scheduling-hint {
font-size: 0.82rem;
color: #c8860a;
}
.cal-cancel-link {
background: none;
border: none;
color: #c8860a;
cursor: pointer;
text-decoration: underline;
font-size: inherit;
padding: 0;
}
.cal-new-ep-banner {
background: #141414;
border-bottom: 1px solid #2a2a2a;
padding: 0.9rem 1.25rem;
flex-shrink: 0;
}
.cal-new-ep-form { max-width: 900px; }
.cal-new-ep-title {
font-size: 0.82rem;
font-weight: 600;
color: #8a8070;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.6rem;
}
.cal-new-ep-row {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 0.6rem;
}
.cal-new-ep-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.77rem;
color: #8a8070;
flex: 1;
min-width: 140px;
}
.cal-new-ep-actions { display: flex; gap: 0.5rem; }
.cal-body {
flex: 1;
display: flex;
overflow: hidden;
}
.cal-main {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
min-width: 0;
}
.cal-loading {
color: #6a6050;
text-align: center;
margin-top: 3rem;
}
.cal-dow-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 2px;
}
.cal-dow {
font-size: 0.72rem;
font-weight: 600;
color: #6a6050;
text-align: center;
padding: 0.35rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.cal-day {
background: #161616;
border: 1px solid #232323;
border-radius: 6px;
min-height: 90px;
padding: 0.4rem;
cursor: default;
display: flex;
flex-direction: column;
transition: border-color 0.12s;
}
.cal-day--out { opacity: 0.35; }
.cal-day--today { border-color: #c8860a; }
.cal-day--selectable { cursor: pointer; }
.cal-day--selectable:hover { background: #1c1c12; border-color: #c8860a66; }
.cal-day-num {
font-size: 0.75rem;
font-weight: 600;
color: #7a7060;
display: block;
margin-bottom: 0.3rem;
}
.cal-day--today .cal-day-num { color: #c8860a; }
.cal-day-events {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.cal-ep-chip {
background: linear-gradient(135deg, #1e2e1a, #172015);
border: 1px solid #2a3a24;
color: #6adb88;
font-size: 0.68rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
cursor: pointer;
text-align: left;
transition: background 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.cal-ep-chip:hover { background: #2a3e24; }
.cal-ep-chip-title { color: #4a8a58; }
/* Calendar event chips (general / recording / social / task) */
.cal-ev-chip {
display: flex;
align-items: center;
gap: 2px;
font-size: 0.68rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
cursor: pointer;
text-align: left;
width: 100%;
border: 1px solid transparent;
transition: opacity 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cal-ev-chip--general { background: #1a2340; border-color: #243060; color: #93c5fd; }
.cal-ev-chip--recording { background: #28183a; border-color: #3a205a; color: #c084fc; }
.cal-ev-chip--social { background: #3a1a28; border-color: #5a2038; color: #f9a8d4; }
.cal-ev-chip--task { background: #2a2010; border-color: #3a3010; color: #fbbf24; }
.cal-ev-chip--done { opacity: 0.45; text-decoration: line-through; }
.cal-ev-chip:hover { filter: brightness(1.15); }
.cal-ev-chip--recurring { opacity: 0.85; }
.cal-ev-icon { flex-shrink: 0; }
.cal-ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; }
/* Time of day */
.cal-chip-time {
color: rgba(255,255,255,0.45);
font-size: 0.63rem;
font-family: system-ui, sans-serif;
margin-left: 2px;
white-space: nowrap;
}
/* Recurrence indicator */
.cal-recur-icon {
font-size: 0.6rem;
margin-left: 2px;
opacity: 0.65;
}
/* View toggle */
.cal-header-center {
display: flex;
align-items: center;
}
.cal-view-toggle {
display: flex;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
}
.cal-view-btn {
background: none;
border: none;
border-right: 1px solid #333;
color: #a09880;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
padding: 0.3rem 0.65rem;
transition: background 0.12s, color 0.12s;
}
.cal-view-btn:last-child { border-right: none; }
.cal-view-btn:hover { background: rgba(255,255,255,0.06); color: #e0d8c8; }
.cal-view-btn--active { background: rgba(201,168,76,0.15); color: #c9a84c; }
/* Week / day grid variants */
.cal-grid--week {
grid-template-rows: minmax(200px, auto);
}
.cal-grid--week .cal-day {
min-height: 200px;
}
.cal-grid--day {
grid-template-columns: 1fr;
padding: 0;
}
.cal-grid--day .cal-day {
min-height: 300px;
border: none;
border-radius: 0;
}
.cal-day--week .cal-day-head { padding: 0.35rem 0.5rem 0; }
.cal-day--day-view { padding: 1rem 1.25rem; }
/* Day header (number + click-to-drill) */
.cal-day-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.cal-day-num--today {
background: #c8860a;
border-radius: 50%;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.2rem;
}
/* Drag-over day highlight */
.cal-day--drag-over {
background: rgba(201,168,76,0.1) !important;
border-color: rgba(201,168,76,0.4) !important;
}
.cal-ep-bell {
font-size: 0.6rem;
margin-left: 2px;
opacity: 0.75;
vertical-align: middle;
}
.cal-reminder-sent {
font-size: 0.72rem;
color: #4ade80;
margin-top: 0.25rem;
display: block;
}
.cal-sidebar {
width: 220px;
flex-shrink: 0;
background: #141414;
border-left: 1px solid #222;
display: flex;
flex-direction: column;
overflow: hidden;
}
.cal-sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: #8a8070;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 0.9rem 0.5rem;
border-bottom: 1px solid #232323;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.cal-sidebar-count {
background: #2e2e2e;
color: #9a9080;
font-size: 0.68rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
}
.cal-sidebar-hint {
font-size: 0.78rem;
color: #c8860a;
padding: 0.4rem 0.9rem 0;
margin: 0;
}
.cal-sidebar-empty {
font-size: 0.82rem;
color: #6a6050;
padding: 0.75rem 0.9rem;
margin: 0;
}
.cal-sidebar-list {
overflow-y: auto;
flex: 1;
padding: 0.4rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cal-sidebar-ep {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 7px;
padding: 0.5rem 0.65rem;
cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.cal-sidebar-ep:hover { border-color: #3a3a3a; }
.cal-sidebar-ep--selected { border-color: #c8860a; background: #1e1a12; }
.cal-sidebar-ep-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.2rem;
}
.cal-sidebar-ep-num {
font-size: 0.72rem;
font-weight: 600;
color: #c8860a;
}
.cal-sidebar-ep-edit {
background: none;
border: none;
color: #5a5040;
cursor: pointer;
font-size: 0.8rem;
padding: 0;
line-height: 1;
}
.cal-sidebar-ep-edit:hover { color: #9a9080; }
.cal-sidebar-ep-title {
font-size: 0.8rem;
color: #c8b898;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-sidebar-ep-series {
font-size: 0.7rem;
color: #6a6050;
margin-top: 0.1rem;
}
/* Calendar popover */
.cal-popover-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.cal-popover {
background: #1e1e1e;
border: 1px solid #333;
border-radius: 12px;
width: 340px;
max-width: 90vw;
box-shadow: 0 16px 40px rgba(0,0,0,0.7);
overflow: hidden;
}
.cal-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid #2a2a2a;
}
.cal-popover-head h3 {
margin: 0;
font-size: 0.95rem;
color: #e8e2d5;
}
.cal-popover-close {
background: none;
border: none;
color: #6a6050;
font-size: 1.2rem;
cursor: pointer;
line-height: 1;
padding: 0;
}
.cal-popover-close:hover { color: #c0b8a8; }
.cal-popover-body {
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.cal-popover-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: #8a8070;
}
.cal-popover-label--row {
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.cal-popover-actions {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid #2a2a2a;
}
/* Email settings panel additions */ /* Email settings panel additions */
.em-settings-section { .em-settings-section {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@@ -14229,14 +13699,6 @@ a.study-track-card:hover {
.em-webhook-add-form { border-top: 1px solid rgba(255,255,255,0.07); padding-top: 1rem; } .em-webhook-add-form { border-top: 1px solid rgba(255,255,255,0.07); padding-top: 1rem; }
.em-webhook-add-title { font-size: 0.85rem; font-weight: 600; color: #c0bcd4; margin: 0 0 0.75rem; } .em-webhook-add-title { font-size: 0.85rem; font-weight: 600; color: #c0bcd4; margin: 0 0 0.75rem; }
/* ── Calendar flash / task generation ───────────────────────────────────── */
.cal-flash-msg { font-size: 0.78rem; color: #4ade80; padding: 0.2rem 0.5rem; border-radius: 4px; background: rgba(74,222,128,0.08); border: 1px solid rgba(74,222,128,0.2); }
/* ── Contacts → Calendar context ────────────────────────────────────────── */
.ct-history-ep-section { margin-top: 0.6rem; border-top: 1px solid rgba(255,255,255,0.07); padding-top: 0.6rem; }
.ct-history-ep-label { font-size: 0.72rem; color: #6b7280; margin: 0 0 0.4rem; text-transform: uppercase; letter-spacing: 0.04em; }
.ct-history-item--ep { background: rgba(99,102,241,0.07); border-left: 2px solid #6366f1; }
/* ── Ko-fi support section ───────────────────────────────────────────────── */ /* ── Ko-fi support section ───────────────────────────────────────────────── */
.section-kofi-support { background: var(--brand-black-2, #0f0f0c); padding: 4.5rem 1.5rem; text-align: center; border-top: 1px solid var(--brand-border, #2a2518); border-bottom: 1px solid var(--brand-border, #2a2518); } .section-kofi-support { background: var(--brand-black-2, #0f0f0c); padding: 4.5rem 1.5rem; text-align: center; border-top: 1px solid var(--brand-border, #2a2518); border-bottom: 1px solid var(--brand-border, #2a2518); }
.kofi-support-inner { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 1.25rem; } .kofi-support-inner { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; gap: 1.25rem; }
-16
View File
@@ -13,7 +13,6 @@ export interface DashboardData {
lastActivityAt: string | null lastActivityAt: string | null
} }
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number } latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
upcoming: { id: string; kind: string; type: string; title: string; date: string; startTime: string | null }[]
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] } nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
health: { health: {
resendConfigured: boolean resendConfigured: boolean
@@ -73,11 +72,6 @@ function formatWhen(value: string | null | undefined) {
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
} }
function formatDay(value: string) {
const d = new Date(`${value}T12:00:00`)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) { export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -230,16 +224,6 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>} {data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
</div> </div>
)} )}
<h3 className="admin-dashboard-activity-heading" style={{ marginTop: '1rem' }}>Upcoming</h3>
{data.upcoming.length === 0 ? <p className="admin-stats-note">Nothing scheduled.</p> : (
<div className="admin-dashboard-feed">
{data.upcoming.map(ev => (
<div key={ev.id} className="admin-dashboard-feed-item admin-dashboard-row">
<span>{ev.title}</span><span className="admin-dashboard-feed-date">{formatDay(ev.date)}{ev.startTime ? ` · ${ev.startTime}` : ''}</span>
</div>
))}
</div>
)}
</div> </div>
</div> </div>