Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f3d76e9b | ||
|
|
326838e5bc | ||
|
|
3a69e03e60 | ||
|
|
900a3efee3 | ||
|
|
ac1b06f945 | ||
|
|
28fc822132 | ||
|
|
f181cdb182 | ||
|
|
5c2f9113b7 | ||
|
|
13d0eb6651 | ||
|
|
99b19faede | ||
|
|
66b7bbef52 | ||
|
|
d6d2c087b2 | ||
|
|
ffb3551b7e |
No files matched your search
@@ -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
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install --production
|
|
||||||
|
|
||||||
COPY server.js ./
|
|
||||||
COPY public/ ./public/
|
|
||||||
|
|
||||||
VOLUME ["/data"]
|
|
||||||
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV DATA_FILE=/data/progress.json
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# Podcast Checklist
|
|
||||||
|
|
||||||
Episode production tracker for *Verse by Verse with Nate*.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
### 1. Build and push to GHCR
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
|
||||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. On the Oracle Cloud VM
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
docker rm -f podcast-checklist 2>/dev/null || true
|
|
||||||
docker run -d \
|
|
||||||
--name podcast-checklist \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 3001:3000 \
|
|
||||||
-v podcast-checklist-data:/data \
|
|
||||||
ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
> Uses port **3001** on the host so it doesn't clash with your existing site on 3000.
|
|
||||||
> Progress is saved to a named Docker volume (`podcast-checklist-data`) so it survives container restarts and re-deploys.
|
|
||||||
|
|
||||||
### 3. Cloudflare DNS
|
|
||||||
|
|
||||||
Add a CNAME record in Cloudflare:
|
|
||||||
- **Name:** `checklist` (or whatever subdomain you want)
|
|
||||||
- **Target:** `versebyversewithnate.us`
|
|
||||||
- **Proxy:** Enabled (orange cloud)
|
|
||||||
|
|
||||||
### 4. Cloudflare Tunnel or Nginx proxy
|
|
||||||
|
|
||||||
If you're using Nginx Proxy Manager or a Cloudflare Tunnel on the VM, add a rule to forward `checklist.versebyversewithnate.us` → `localhost:3001`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Update workflow
|
|
||||||
|
|
||||||
Same as your site:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local
|
|
||||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
|
||||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
|
|
||||||
# SSH into VM
|
|
||||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
docker rm -f podcast-checklist
|
|
||||||
docker run -d \
|
|
||||||
--name podcast-checklist \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-p 3001:3000 \
|
|
||||||
-v podcast-checklist-data:/data \
|
|
||||||
ghcr.io/nmemmert/podcast-checklist:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Progress data is in the named volume and is **not affected** by pulling a new image.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "podcast-checklist",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Verse by Verse with Nate — Episode Production Checklist",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Episode Checklist — Verse by Verse with Nate</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,600&display=swap" rel="stylesheet" />
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--black: #0d0d0a;
|
|
||||||
--card-bg: #111108;
|
|
||||||
--border: #1e1c14;
|
|
||||||
--border-mid: #2a2518;
|
|
||||||
--gold: #c9a84c;
|
|
||||||
--gold-light: #e0c070;
|
|
||||||
--warm-white: #f0ead8;
|
|
||||||
--warm-gray: #7a7060;
|
|
||||||
--muted: #4a4438;
|
|
||||||
--dim: #3a3020;
|
|
||||||
--deep: #252018;
|
|
||||||
}
|
|
||||||
|
|
||||||
body { background: var(--black); color: var(--warm-white); font-family: 'Cormorant Garamond', Georgia, serif; min-height: 100vh; }
|
|
||||||
::-webkit-scrollbar { width: 4px; }
|
|
||||||
::-webkit-scrollbar-track { background: var(--black); }
|
|
||||||
::-webkit-scrollbar-thumb { background: var(--border-mid); }
|
|
||||||
|
|
||||||
.root { max-width: 720px; margin: 0 auto; padding: 48px 20px 100px; }
|
|
||||||
|
|
||||||
.masthead { text-align: center; margin-bottom: 40px; }
|
|
||||||
.eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--warm-gray); margin-bottom: 10px; }
|
|
||||||
.main-title { font-size: clamp(28px, 6vw, 44px); font-weight: 300; font-style: italic; color: var(--gold); line-height: 1.1; margin-bottom: 4px; }
|
|
||||||
.subtitle { font-size: 13px; color: var(--muted); letter-spacing: 0.1em; }
|
|
||||||
.gold-rule { width: 80px; height: 1px; background: linear-gradient(90deg, transparent, var(--gold), transparent); margin: 18px auto; }
|
|
||||||
|
|
||||||
.save-status { text-align: center; font-size: 11px; letter-spacing: 0.12em; color: var(--dim); margin-bottom: 24px; height: 16px; transition: color 0.3s; }
|
|
||||||
.save-status.saving { color: var(--warm-gray); }
|
|
||||||
.save-status.saved { color: var(--gold); }
|
|
||||||
.save-status.error { color: #8b3030; }
|
|
||||||
|
|
||||||
.overall { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; padding: 18px 22px; margin-bottom: 32px; }
|
|
||||||
.overall-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
|
|
||||||
.overall-label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); }
|
|
||||||
.overall-pct { font-size: 28px; font-weight: 300; color: var(--gold); }
|
|
||||||
.bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 14px; }
|
|
||||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); border-radius: 2px; transition: width 0.4s ease; }
|
|
||||||
.series-stats { display: flex; gap: 24px; flex-wrap: wrap; }
|
|
||||||
.stat { font-size: 12px; color: var(--muted); }
|
|
||||||
.stat b { color: var(--gold); font-weight: 500; }
|
|
||||||
|
|
||||||
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
||||||
.filter-btn { font-family: 'Cormorant Garamond', serif; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; background: none; border: 1px solid var(--border-mid); color: var(--muted); padding: 6px 14px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
|
||||||
.filter-btn:hover { color: var(--gold); border-color: var(--dim); }
|
|
||||||
.filter-btn.active { color: var(--gold); border-color: var(--gold); background: var(--card-bg); }
|
|
||||||
|
|
||||||
.section-label { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--dim); margin: 28px 0 12px; padding-left: 2px; }
|
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s; }
|
|
||||||
.card:hover { border-color: var(--border-mid); }
|
|
||||||
.card.complete { border-left: 2px solid var(--gold); }
|
|
||||||
|
|
||||||
.card-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer; user-select: none; }
|
|
||||||
|
|
||||||
.series-tag { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; padding: 2px 7px; border-radius: 1px; flex-shrink: 0; }
|
|
||||||
.tag-Titus { color: var(--gold); background: #1a1810; border: 1px solid var(--border-mid); }
|
|
||||||
.tag-Colossians { color: var(--warm-gray); background: #131310; border: 1px solid var(--border); }
|
|
||||||
|
|
||||||
.ep-num { font-size: 13px; font-weight: 600; color: var(--muted); flex-shrink: 0; min-width: 36px; }
|
|
||||||
.card.complete .ep-num { color: var(--gold); }
|
|
||||||
|
|
||||||
.ep-label { flex: 1; font-size: 15px; color: #c8c0a8; }
|
|
||||||
.card.complete .ep-label { color: var(--muted); }
|
|
||||||
|
|
||||||
.pub-date-pill { font-size: 10px; color: var(--warm-gray); font-style: italic; flex-shrink: 0; letter-spacing: 0.03em; }
|
|
||||||
|
|
||||||
.task-count { font-size: 11px; color: var(--dim); flex-shrink: 0; }
|
|
||||||
.card.complete .task-count { color: var(--gold); }
|
|
||||||
|
|
||||||
.mini-bar { width: 48px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; flex-shrink: 0; }
|
|
||||||
.mini-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); transition: width 0.3s; }
|
|
||||||
|
|
||||||
.chevron { font-size: 10px; color: var(--border-mid); transition: transform 0.2s, color 0.15s; flex-shrink: 0; }
|
|
||||||
.chevron.open { transform: rotate(180deg); color: var(--muted); }
|
|
||||||
|
|
||||||
.card-body { border-top: 1px solid #1a1810; padding: 0 18px 18px; }
|
|
||||||
|
|
||||||
/* Date published row */
|
|
||||||
.date-row { display: flex; align-items: center; gap: 12px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
|
||||||
.date-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); flex-shrink: 0; }
|
|
||||||
.date-input {
|
|
||||||
background: transparent; border: none; border-bottom: 1px solid var(--border-mid);
|
|
||||||
outline: none; font-family: 'Cormorant Garamond', serif; font-size: 14px;
|
|
||||||
color: var(--gold-light); padding: 2px 6px 3px; cursor: pointer;
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
.date-input:focus { border-bottom-color: var(--gold); }
|
|
||||||
.date-input::-webkit-calendar-picker-indicator { filter: invert(0.6) sepia(1) saturate(2) hue-rotate(5deg); cursor: pointer; }
|
|
||||||
|
|
||||||
/* Phase headers */
|
|
||||||
.phase-header { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: var(--warm-gray); padding: 14px 0 6px; border-bottom: 1px solid var(--border); margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
|
|
||||||
.phase-header::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
|
||||||
.phase-pre { color: var(--warm-gray); }
|
|
||||||
.phase-post { color: var(--gold); }
|
|
||||||
|
|
||||||
.task-list { list-style: none; }
|
|
||||||
.task-item { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-bottom: 1px solid #141210; cursor: pointer; }
|
|
||||||
.task-item:last-child { border-bottom: none; }
|
|
||||||
|
|
||||||
.check-box { width: 16px; height: 16px; border: 1px solid var(--border-mid); border-radius: 1px; flex-shrink: 0; margin-top: 3px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; }
|
|
||||||
.task-item.done .check-box { background: var(--gold); border-color: var(--gold); }
|
|
||||||
.check-svg { display: none; }
|
|
||||||
.task-item.done .check-svg { display: block; }
|
|
||||||
|
|
||||||
.step-num { font-size: 9px; letter-spacing: 0.15em; color: var(--border-mid); margin-top: 4px; flex-shrink: 0; width: 22px; }
|
|
||||||
|
|
||||||
.task-body { flex: 1; }
|
|
||||||
.task-name { font-size: 14px; font-weight: 500; color: #d8d0b8; line-height: 1.3; transition: color 0.15s; }
|
|
||||||
.task-item.done .task-name { color: var(--dim); text-decoration: line-through; text-decoration-color: var(--border-mid); }
|
|
||||||
.task-hint { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 1px; }
|
|
||||||
.task-item.done .task-hint { color: var(--deep); }
|
|
||||||
.task-item:hover .task-name { color: var(--warm-white); }
|
|
||||||
.task-item.done:hover .task-name { color: var(--dim); }
|
|
||||||
|
|
||||||
.card-footer { display: flex; justify-content: flex-end; padding-top: 10px; margin-top: 4px; border-top: 1px solid #141210; }
|
|
||||||
.btn-reset { font-family: 'Cormorant Garamond', serif; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; background: none; border: 1px solid var(--border); color: var(--dim); padding: 4px 10px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
|
||||||
.btn-reset:hover { color: var(--warm-gray); border-color: var(--border-mid); }
|
|
||||||
.complete-badge { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gold); padding: 2px 8px; border: 1px solid var(--border-mid); border-radius: 1px; }
|
|
||||||
|
|
||||||
.loading { text-align: center; color: var(--muted); font-style: italic; padding: 40px; font-size: 15px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="root">
|
|
||||||
|
|
||||||
<div class="masthead">
|
|
||||||
<div class="eyebrow">Verse by Verse with Nate</div>
|
|
||||||
<div class="main-title">Production Checklist</div>
|
|
||||||
<div class="subtitle">Titus · Colossians</div>
|
|
||||||
<div class="gold-rule"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="save-status" id="saveStatus"></div>
|
|
||||||
|
|
||||||
<div class="overall">
|
|
||||||
<div class="overall-row">
|
|
||||||
<div class="overall-label">Overall Progress</div>
|
|
||||||
<div class="overall-pct" id="overallPct">0%</div>
|
|
||||||
</div>
|
|
||||||
<div class="bar-wrap"><div class="bar-fill" id="overallBar" style="width:0%"></div></div>
|
|
||||||
<div class="series-stats">
|
|
||||||
<div class="stat">Titus — <b id="titusDone">0</b> / 5 episodes done</div>
|
|
||||||
<div class="stat">Colossians — <b id="colossDone">0</b> / 27 episodes done</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters">
|
|
||||||
<button class="filter-btn active" onclick="setFilter('All')">All</button>
|
|
||||||
<button class="filter-btn" onclick="setFilter('Titus')">Titus</button>
|
|
||||||
<button class="filter-btn" onclick="setFilter('Colossians')">Colossians</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="app"><div class="loading">Loading…</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ── Task definitions ────────────────────────────────────────────────────────
|
|
||||||
const PRE_TASKS = [
|
|
||||||
{ id: "verify_script", step: "01", label: "Verify Script", desc: "Review for accuracy, theology, and flow" },
|
|
||||||
{ id: "read_script", step: "02", label: "Read Script", desc: "Read aloud, mark pacing & emphasis" },
|
|
||||||
{ id: "record", step: "03", label: "Record", desc: "Capture final audio take" },
|
|
||||||
{ id: "edit", step: "04", label: "Edit", desc: "Trim, cut, clean up in Ferrite / GarageBand" },
|
|
||||||
{ id: "mix", step: "05", label: "Mix", desc: "Levels, EQ, music beds, final master" },
|
|
||||||
{ id: "video_script", step: "06", label: "Run Video Conversion Script", desc: "Generate video file from audio" },
|
|
||||||
{ id: "post_spotify", step: "07", label: "Post on Spotify", desc: "Upload with show notes & discussion questions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const POST_TASKS = [
|
|
||||||
{ id: "update_website", step: "08", label: "Update Website", desc: "Add episode highlights to versebyversewithnate.us" },
|
|
||||||
{ id: "send_email", step: "09", label: "Send Email", desc: "Send newsletter to subscribers via Resend" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALL_TASK_IDS = [...PRE_TASKS, ...POST_TASKS].map(t => t.id);
|
|
||||||
|
|
||||||
function makeEpisode(series, number) {
|
|
||||||
const tasks = {};
|
|
||||||
ALL_TASK_IDS.forEach(id => tasks[id] = false);
|
|
||||||
return { id: `${series}-${number}`, series, number, tasks, datePublished: "", expanded: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEpisodes() {
|
|
||||||
const titus = [11,12,13,14,15].map(n => makeEpisode("Titus", n));
|
|
||||||
const colossians = Array.from({ length: 27 }, (_, i) => makeEpisode("Colossians", i + 1));
|
|
||||||
return [...titus, ...colossians];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────────────
|
|
||||||
let episodes = [];
|
|
||||||
let activeFilter = "All";
|
|
||||||
let saveTimer = null;
|
|
||||||
|
|
||||||
// ── Server I/O ──────────────────────────────────────────────────────────────
|
|
||||||
async function loadFromServer() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/progress");
|
|
||||||
const saved = await res.json();
|
|
||||||
const base = buildEpisodes();
|
|
||||||
episodes = base.map(ep => {
|
|
||||||
const s = saved[ep.id];
|
|
||||||
if (!s) return ep;
|
|
||||||
return {
|
|
||||||
...ep,
|
|
||||||
expanded: s.expanded ?? false,
|
|
||||||
datePublished: s.datePublished ?? "",
|
|
||||||
tasks: Object.fromEntries(ALL_TASK_IDS.map(id => [id, s.tasks?.[id] ?? false]))
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
episodes = buildEpisodes();
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleSave() {
|
|
||||||
clearTimeout(saveTimer);
|
|
||||||
setStatus("saving", "Saving…");
|
|
||||||
saveTimer = setTimeout(saveToServer, 800);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveToServer() {
|
|
||||||
const payload = {};
|
|
||||||
episodes.forEach(ep => {
|
|
||||||
payload[ep.id] = { expanded: ep.expanded, datePublished: ep.datePublished, tasks: ep.tasks };
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await fetch("/api/progress", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
setStatus("saved", "✦ Saved");
|
|
||||||
setTimeout(() => setStatus("", ""), 2000);
|
|
||||||
} catch {
|
|
||||||
setStatus("error", "Save failed — check connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(cls, msg) {
|
|
||||||
const el = document.getElementById("saveStatus");
|
|
||||||
el.className = "save-status" + (cls ? " " + cls : "");
|
|
||||||
el.textContent = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
|
||||||
function toggleTask(epId, taskId) {
|
|
||||||
const ep = episodes.find(e => e.id === epId);
|
|
||||||
ep.tasks[taskId] = !ep.tasks[taskId];
|
|
||||||
scheduleSave(); render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExpand(epId) {
|
|
||||||
const ep = episodes.find(e => e.id === epId);
|
|
||||||
ep.expanded = !ep.expanded;
|
|
||||||
scheduleSave(); render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDate(epId, val) {
|
|
||||||
const ep = episodes.find(e => e.id === epId);
|
|
||||||
ep.datePublished = val;
|
|
||||||
scheduleSave(); render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetEpisode(epId) {
|
|
||||||
const ep = episodes.find(e => e.id === epId);
|
|
||||||
ALL_TASK_IDS.forEach(id => ep.tasks[id] = false);
|
|
||||||
ep.datePublished = "";
|
|
||||||
scheduleSave(); render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setFilter(f) {
|
|
||||||
activeFilter = f;
|
|
||||||
document.querySelectorAll(".filter-btn").forEach(btn =>
|
|
||||||
btn.classList.toggle("active", btn.textContent === f)
|
|
||||||
);
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────────────
|
|
||||||
function render() {
|
|
||||||
updateStats();
|
|
||||||
const app = document.getElementById("app");
|
|
||||||
const filtered = activeFilter === "All" ? episodes : episodes.filter(e => e.series === activeFilter);
|
|
||||||
const titus = filtered.filter(e => e.series === "Titus");
|
|
||||||
const colossians = filtered.filter(e => e.series === "Colossians");
|
|
||||||
|
|
||||||
let html = "";
|
|
||||||
if (titus.length) {
|
|
||||||
html += `<div class="section-label">Titus — Episodes 11–15</div>`;
|
|
||||||
titus.forEach(ep => html += renderCard(ep));
|
|
||||||
}
|
|
||||||
if (colossians.length) {
|
|
||||||
html += `<div class="section-label">Colossians — Episodes 1–27</div>`;
|
|
||||||
colossians.forEach(ep => html += renderCard(ep));
|
|
||||||
}
|
|
||||||
app.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(val) {
|
|
||||||
if (!val) return "";
|
|
||||||
const [y, m, d] = val.split("-");
|
|
||||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
|
||||||
return `${months[parseInt(m)-1]} ${parseInt(d)}, ${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCard(ep) {
|
|
||||||
const allDone = ALL_TASK_IDS.every(id => ep.tasks[id]);
|
|
||||||
const doneCount = ALL_TASK_IDS.filter(id => ep.tasks[id]).length;
|
|
||||||
const total = ALL_TASK_IDS.length;
|
|
||||||
const pct = Math.round((doneCount / total) * 100);
|
|
||||||
const dateDisplay = ep.datePublished ? formatDate(ep.datePublished) : "";
|
|
||||||
|
|
||||||
let body = "";
|
|
||||||
if (ep.expanded) {
|
|
||||||
// Pre-publish tasks
|
|
||||||
const preTasks = PRE_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
|
||||||
// Post-publish tasks
|
|
||||||
const postTasks = POST_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
|
||||||
|
|
||||||
const footer = allDone
|
|
||||||
? `<span class="complete-badge">✦ Complete</span>`
|
|
||||||
: `<button class="btn-reset" onclick="event.stopPropagation();resetEpisode('${ep.id}')">Reset</button>`;
|
|
||||||
|
|
||||||
body = `
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="date-row">
|
|
||||||
<span class="date-label">Date Published</span>
|
|
||||||
<input type="date" class="date-input" value="${ep.datePublished}"
|
|
||||||
onchange="setDate('${ep.id}', this.value)"
|
|
||||||
onclick="event.stopPropagation()" />
|
|
||||||
${ep.datePublished ? `<span style="font-size:12px;color:var(--warm-gray);font-style:italic;">${formatDate(ep.datePublished)}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="phase-header phase-pre">Pre-Publish</div>
|
|
||||||
<ul class="task-list">${preTasks}</ul>
|
|
||||||
|
|
||||||
<div class="phase-header phase-post" style="margin-top:10px;">Post-Publish</div>
|
|
||||||
<ul class="task-list">${postTasks}</ul>
|
|
||||||
|
|
||||||
<div class="card-footer">${footer}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="card${allDone ? " complete" : ""}">
|
|
||||||
<div class="card-header" onclick="toggleExpand('${ep.id}')">
|
|
||||||
<span class="series-tag tag-${ep.series}">${ep.series}</span>
|
|
||||||
<span class="ep-num">Ep ${ep.number}</span>
|
|
||||||
<span class="ep-label">${ep.series} — Episode ${ep.number}</span>
|
|
||||||
${dateDisplay ? `<span class="pub-date-pill">${dateDisplay}</span>` : ""}
|
|
||||||
<span class="task-count">${doneCount}/${total}</span>
|
|
||||||
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%"></div></div>
|
|
||||||
<span class="chevron${ep.expanded ? " open" : ""}">▼</span>
|
|
||||||
</div>
|
|
||||||
${body}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTask(epId, t, done) {
|
|
||||||
return `
|
|
||||||
<li class="task-item${done ? " done" : ""}" onclick="toggleTask('${epId}','${t.id}')">
|
|
||||||
<div class="check-box">
|
|
||||||
<svg class="check-svg" width="10" height="8" viewBox="0 0 10 8" fill="none">
|
|
||||||
<path d="M1 4L3.5 6.5L9 1" stroke="#0d0d0a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="step-num">${t.step}</span>
|
|
||||||
<div class="task-body">
|
|
||||||
<div class="task-name">${t.label}</div>
|
|
||||||
<div class="task-hint">${t.desc}</div>
|
|
||||||
</div>
|
|
||||||
</li>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStats() {
|
|
||||||
const totalDone = episodes.reduce((a, e) => a + ALL_TASK_IDS.filter(id => e.tasks[id]).length, 0);
|
|
||||||
const totalTasks = episodes.length * ALL_TASK_IDS.length;
|
|
||||||
const pct = Math.round((totalDone / totalTasks) * 100);
|
|
||||||
document.getElementById("overallPct").textContent = pct + "%";
|
|
||||||
document.getElementById("overallBar").style.width = pct + "%";
|
|
||||||
document.getElementById("titusDone").textContent =
|
|
||||||
episodes.filter(e => e.series === "Titus" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
|
||||||
document.getElementById("colossDone").textContent =
|
|
||||||
episodes.filter(e => e.series === "Colossians" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFromServer();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
const express = require("express");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const DATA_FILE = process.env.DATA_FILE || "/data/progress.json";
|
|
||||||
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
|
||||||
|
|
||||||
// Ensure data directory and file exist
|
|
||||||
function ensureDataFile() {
|
|
||||||
const dir = path.dirname(DATA_FILE);
|
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
||||||
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify({}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET progress
|
|
||||||
app.get("/api/progress", (req, res) => {
|
|
||||||
try {
|
|
||||||
ensureDataFile();
|
|
||||||
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
|
||||||
res.json(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Read error:", err);
|
|
||||||
res.status(500).json({ error: "Failed to read progress" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST progress (full state)
|
|
||||||
app.post("/api/progress", (req, res) => {
|
|
||||||
try {
|
|
||||||
ensureDataFile();
|
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Write error:", err);
|
|
||||||
res.status(500).json({ error: "Failed to save progress" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => console.log(`Podcast checklist running on port ${PORT}`));
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user