Remove standalone podcast-checklist app; admin checklist now uses the existing checklist backend; v2.14.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d6d2c087b2
commit
66b7bbef52
10 files changed
+101
-683
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"version": "2.14.0",
|
||||
"version": "2.14.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "siteforge",
|
||||
"version": "2.14.0",
|
||||
"version": "2.14.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.14.0",
|
||||
"version": "2.14.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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}`));
|
||||
@@ -70,7 +70,6 @@ import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||
import { register as registerPodcastChecklist } from './server/routes/podcast-checklist.js'
|
||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||
import { register as registerFeed } from './server/routes/feed.js'
|
||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||
@@ -129,7 +128,6 @@ registerEpisodes(app)
|
||||
registerNuggets(app)
|
||||
registerNuggetsHostUpload(app)
|
||||
registerNuggetsWorkflow(app)
|
||||
registerPodcastChecklist(app)
|
||||
registerFeed(app)
|
||||
registerQrCodes(app)
|
||||
registerBiblePassage(app)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { writeFile } from '../atomic-write.js'
|
||||
import { requireAdminAuth } from '../auth.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
const CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||
|
||||
export const CHECKLIST_TASK_IDS = [
|
||||
'verify_script', 'read_script', 'record', 'edit', 'mix', 'video_script', 'post_spotify',
|
||||
'update_website', 'send_email',
|
||||
]
|
||||
|
||||
const LANES = ['A', 'B']
|
||||
|
||||
function sanitizeEpisodes(raw) {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.slice(0, 500).filter(e => e && typeof e === 'object').map(e => ({
|
||||
id: typeof e.id === 'string' && e.id ? e.id.slice(0, 64) : randomUUID(),
|
||||
number: Number.isFinite(Number(e.number)) ? Number(e.number) : 0,
|
||||
title: typeof e.title === 'string' ? e.title.trim().slice(0, 160) : '',
|
||||
datePublished: typeof e.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.datePublished) ? e.datePublished : '',
|
||||
tasks: Object.fromEntries(CHECKLIST_TASK_IDS.map(id => [id, e.tasks?.[id] === true])),
|
||||
}))
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = JSON.parse(await readFile(CHECKLIST_FILE, 'utf8'))
|
||||
return Object.fromEntries(LANES.map(l => [l, sanitizeEpisodes(data[l])]))
|
||||
} catch {
|
||||
return { A: [], B: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin/podcast-checklist', requireAdminAuth, async (_req, res) => {
|
||||
res.json(await load())
|
||||
})
|
||||
|
||||
app.put('/api/admin/podcast-checklist', requireAdminAuth, async (req, res) => {
|
||||
const data = Object.fromEntries(LANES.map(l => [l, sanitizeEpisodes(req.body?.[l])]))
|
||||
await writeFile(CHECKLIST_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
+98
-94
@@ -1800,33 +1800,21 @@ function NuggetsWorkflowAdmin() {
|
||||
}
|
||||
|
||||
// ── Podcast production checklist (Lane A / Lane B) ─────────────────────────
|
||||
const CHECKLIST_PRE = [
|
||||
{ id: 'verify_script', label: 'Verify Script', desc: 'Review for accuracy, theology, and flow' },
|
||||
{ id: 'read_script', label: 'Read Script', desc: 'Read aloud, mark pacing & emphasis' },
|
||||
{ id: 'record', label: 'Record', desc: 'Capture final audio take' },
|
||||
{ id: 'edit', label: 'Edit', desc: 'Trim, cut, clean up' },
|
||||
{ id: 'mix', label: 'Mix', desc: 'Levels, EQ, music beds, final master' },
|
||||
{ id: 'video_script', label: 'Run Video Conversion Script', desc: 'Generate video file from audio' },
|
||||
{ id: 'post_spotify', label: 'Post on Spotify', desc: 'Upload with show notes & discussion questions' },
|
||||
]
|
||||
const CHECKLIST_POST = [
|
||||
{ id: 'update_website', label: 'Update Website', desc: 'Add episode highlights to the site' },
|
||||
{ id: 'send_email', label: 'Send Email', desc: 'Send newsletter to subscribers' },
|
||||
]
|
||||
const CHECKLIST_ALL = [...CHECKLIST_PRE, ...CHECKLIST_POST]
|
||||
|
||||
interface ChecklistTask { id: string; label: string; phase: 'pre' | 'post' }
|
||||
interface ChecklistEpisode {
|
||||
id: string
|
||||
number: number
|
||||
series: string
|
||||
episodeNumber: number | null
|
||||
title: string
|
||||
datePublished: string
|
||||
tasks: Record<string, boolean>
|
||||
[extra: string]: unknown // reminderDays, startTime etc. are preserved untouched
|
||||
}
|
||||
type ChecklistLane = 'A' | 'B'
|
||||
interface PodcastChecklist { tasks: ChecklistTask[]; episodes: ChecklistEpisode[] }
|
||||
type ChecklistLane = 'A' | 'B' | 'other'
|
||||
|
||||
function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string; laneBTitle: string }) {
|
||||
const [data, setData] = useState<Record<ChecklistLane, ChecklistEpisode[]>>({ A: [], B: [] })
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [checklist, setChecklist] = useState<PodcastChecklist | null>(null)
|
||||
const [lane, setLane] = useState<ChecklistLane>('A')
|
||||
const [openId, setOpenId] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
@@ -1834,23 +1822,22 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
|
||||
const saveTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/podcast-checklist')
|
||||
fetch('/api/admin-podcast-checklist')
|
||||
.then(r => r.json())
|
||||
.then(d => setData({ A: d.A ?? [], B: d.B ?? [] }))
|
||||
.then(d => setChecklist(d.checklist))
|
||||
.catch(() => setStatus('Could not load checklist.'))
|
||||
.finally(() => setLoaded(true))
|
||||
}, [])
|
||||
|
||||
const persist = (next: Record<ChecklistLane, ChecklistEpisode[]>) => {
|
||||
setData(next)
|
||||
const persist = (next: PodcastChecklist) => {
|
||||
setChecklist(next)
|
||||
setStatus('Saving…')
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current)
|
||||
saveTimer.current = window.setTimeout(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/admin/podcast-checklist', {
|
||||
const r = await fetch('/api/admin-podcast-checklist', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(next),
|
||||
body: JSON.stringify({ checklist: next }),
|
||||
})
|
||||
setStatus(r.ok ? 'Saved' : 'Save failed')
|
||||
} catch {
|
||||
@@ -1859,44 +1846,68 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const episodes = data[lane]
|
||||
if (!checklist) {
|
||||
return (
|
||||
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||||
<div className="admin-panel-head"><h2>Podcast — Production Checklist</h2></div>
|
||||
<p className="admin-stats-note">{status || 'Loading…'}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const matches = (series: string, title: string) => {
|
||||
const a = series.trim().toLowerCase()
|
||||
const b = title.trim().toLowerCase()
|
||||
return Boolean(a && b && (b.includes(a) || a.includes(b)))
|
||||
}
|
||||
const laneOf = (e: ChecklistEpisode): ChecklistLane =>
|
||||
matches(e.series, laneATitle) ? 'A' : matches(e.series, laneBTitle) ? 'B' : 'other'
|
||||
const laneEpisodes = (l: ChecklistLane) => checklist.episodes.filter(e => laneOf(e) === l)
|
||||
const tasks = checklist.tasks
|
||||
const doneCount = (e: ChecklistEpisode) => tasks.filter(t => e.tasks[t.id]).length
|
||||
const isDone = (e: ChecklistEpisode) => tasks.length > 0 && doneCount(e) === tasks.length
|
||||
|
||||
const update = (id: string, patch: Partial<ChecklistEpisode>) =>
|
||||
persist({ ...data, [lane]: episodes.map(e => (e.id === id ? { ...e, ...patch } : e)) })
|
||||
persist({ ...checklist, episodes: checklist.episodes.map(e => (e.id === id ? { ...e, ...patch } : e)) })
|
||||
const toggle = (ep: ChecklistEpisode, taskId: string) =>
|
||||
update(ep.id, { tasks: { ...ep.tasks, [taskId]: !ep.tasks[taskId] } })
|
||||
|
||||
const addEpisodes = () => {
|
||||
if (lane === 'other') return
|
||||
const seriesName = (lane === 'A' ? laneATitle : laneBTitle).trim()
|
||||
if (!seriesName) { setStatus('Set this lane’s series title in Current Series first.'); return }
|
||||
const n = Math.max(1, Math.min(50, parseInt(bulk, 10) || 1))
|
||||
const start = episodes.reduce((m, e) => Math.max(m, e.number), 0) + 1
|
||||
const start = laneEpisodes(lane).reduce((m, e) => Math.max(m, e.episodeNumber ?? 0), 0) + 1
|
||||
const added: ChecklistEpisode[] = Array.from({ length: n }, (_, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
number: start + i,
|
||||
series: seriesName,
|
||||
episodeNumber: start + i,
|
||||
title: '',
|
||||
datePublished: '',
|
||||
tasks: Object.fromEntries(CHECKLIST_ALL.map(t => [t.id, false])),
|
||||
tasks: Object.fromEntries(tasks.map(t => [t.id, false])),
|
||||
}))
|
||||
persist({ ...data, [lane]: [...episodes, ...added] })
|
||||
persist({ ...checklist, episodes: [...checklist.episodes, ...added] })
|
||||
}
|
||||
|
||||
const removeEpisode = (id: string) => {
|
||||
if (!confirm('Delete this episode from the checklist?')) return
|
||||
persist({ ...data, [lane]: episodes.filter(e => e.id !== id) })
|
||||
persist({ ...checklist, episodes: checklist.episodes.filter(e => e.id !== id) })
|
||||
}
|
||||
|
||||
const doneCount = (e: ChecklistEpisode) => CHECKLIST_ALL.filter(t => e.tasks[t.id]).length
|
||||
const laneDone = episodes.filter(e => doneCount(e) === CHECKLIST_ALL.length).length
|
||||
const sorted = [...episodes].sort((a, b) => a.number - b.number)
|
||||
const lanes: { key: ChecklistLane; label: string }[] = [
|
||||
{ key: 'A', label: `Lane A — ${laneATitle || 'Primary series'}` },
|
||||
{ key: 'B', label: `Lane B — ${laneBTitle || 'Second series'}` },
|
||||
...(laneEpisodes('other').length > 0 ? [{ key: 'other' as ChecklistLane, label: 'Other series' }] : []),
|
||||
]
|
||||
const sorted = [...laneEpisodes(lane)].sort((a, b) => (a.episodeNumber ?? 0) - (b.episodeNumber ?? 0))
|
||||
|
||||
const renderTasks = (ep: ChecklistEpisode, tasks: typeof CHECKLIST_PRE) => (
|
||||
const renderTasks = (ep: ChecklistEpisode, phase: 'pre' | 'post') => (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '4px 0 10px' }}>
|
||||
{tasks.map(t => (
|
||||
{tasks.filter(t => t.phase === phase).map(t => (
|
||||
<li key={t.id} style={{ padding: '4px 0' }}>
|
||||
<label style={{ display: 'flex', gap: 8, cursor: 'pointer', alignItems: 'flex-start' }}>
|
||||
<input type="checkbox" checked={Boolean(ep.tasks[t.id])} onChange={() => toggle(ep, t.id)} style={{ marginTop: 4 }} />
|
||||
<span>
|
||||
<span style={{ textDecoration: ep.tasks[t.id] ? 'line-through' : 'none', opacity: ep.tasks[t.id] ? 0.6 : 1 }}>{t.label}</span>
|
||||
<span style={{ display: 'block', fontSize: '0.78rem', opacity: 0.65 }}>{t.desc}</span>
|
||||
</span>
|
||||
<label style={{ display: 'flex', gap: 8, cursor: 'pointer', alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={Boolean(ep.tasks[t.id])} onChange={() => toggle(ep, t.id)} />
|
||||
<span style={{ textDecoration: ep.tasks[t.id] ? 'line-through' : 'none', opacity: ep.tasks[t.id] ? 0.6 : 1 }}>{t.label}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
@@ -1907,65 +1918,58 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
|
||||
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Podcast — Production Checklist</h2>
|
||||
<p>Track every episode from script to newsletter, one lane per running series.</p>
|
||||
<p>Track every episode from script to newsletter, one lane per running series. Published dates feed the calendar and reminders.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{(['A', 'B'] as ChecklistLane[]).map(l => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
className={lane === l ? 'btn-admin-save' : 'btn-admin-ghost'}
|
||||
onClick={() => { setLane(l); setOpenId('') }}
|
||||
>
|
||||
Lane {l} — {(l === 'A' ? laneATitle : laneBTitle) || (l === 'A' ? 'Primary series' : 'Second series')} ({data[l].filter(e => doneCount(e) === CHECKLIST_ALL.length).length}/{data[l].length})
|
||||
{lanes.map(l => (
|
||||
<button key={l.key} type="button" className={lane === l.key ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane(l.key); setOpenId('') }}>
|
||||
{l.label} ({laneEpisodes(l.key).filter(isDone).length}/{laneEpisodes(l.key).length})
|
||||
</button>
|
||||
))}
|
||||
<span className="admin-stats-note" style={{ alignSelf: 'center' }}>{status}</span>
|
||||
</div>
|
||||
|
||||
{!loaded ? <p className="admin-stats-note">Loading…</p> : (
|
||||
<>
|
||||
<p className="admin-stats-note">{laneDone} of {episodes.length} episodes complete.</p>
|
||||
{sorted.map(ep => {
|
||||
const done = doneCount(ep)
|
||||
const open = openId === ep.id
|
||||
return (
|
||||
<div key={ep.id} style={{ border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6, marginBottom: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(open ? '' : ep.id)}
|
||||
style={{ display: 'flex', width: '100%', gap: 12, padding: '8px 12px', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', textAlign: 'left', alignItems: 'center' }}
|
||||
>
|
||||
<strong>Ep {ep.number}</strong>
|
||||
<span style={{ flex: 1 }}>{ep.title}</span>
|
||||
{ep.datePublished && <span style={{ fontSize: '0.8rem', opacity: 0.7 }}>{ep.datePublished}</span>}
|
||||
<span style={{ fontSize: '0.8rem' }}>{done}/{CHECKLIST_ALL.length}{done === CHECKLIST_ALL.length ? ' ✓' : ''}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
<input type="number" aria-label="Episode number" value={ep.number} min={0} style={{ width: 80 }}
|
||||
onChange={e => update(ep.id, { number: Number(e.target.value) || 0 })} />
|
||||
<input type="text" aria-label="Episode title" placeholder="Episode title (optional)" value={ep.title} style={{ flex: 1, minWidth: 180 }}
|
||||
onChange={e => update(ep.id, { title: e.target.value })} />
|
||||
<input type="date" aria-label="Date published" value={ep.datePublished}
|
||||
onChange={e => update(ep.id, { datePublished: e.target.value })} />
|
||||
</div>
|
||||
<strong style={{ fontSize: '0.8rem' }}>Pre-publish</strong>
|
||||
{renderTasks(ep, CHECKLIST_PRE)}
|
||||
<strong style={{ fontSize: '0.8rem' }}>Post-publish</strong>
|
||||
{renderTasks(ep, CHECKLIST_POST)}
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeEpisode(ep.id)}>Delete episode</button>
|
||||
</div>
|
||||
)}
|
||||
{sorted.map(ep => {
|
||||
const done = doneCount(ep)
|
||||
const open = openId === ep.id
|
||||
return (
|
||||
<div key={ep.id} style={{ border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6, marginBottom: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(open ? '' : ep.id)}
|
||||
style={{ display: 'flex', width: '100%', gap: 12, padding: '8px 12px', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', textAlign: 'left', alignItems: 'center' }}
|
||||
>
|
||||
<strong>Ep {ep.episodeNumber ?? '?'}</strong>
|
||||
<span style={{ flex: 1 }}>{ep.title}</span>
|
||||
{ep.datePublished && <span style={{ fontSize: '0.8rem', opacity: 0.7 }}>{ep.datePublished}</span>}
|
||||
<span style={{ fontSize: '0.8rem' }}>{done}/{tasks.length}{isDone(ep) ? ' ✓' : ''}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ padding: '0 12px 12px' }}>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
<input type="number" aria-label="Episode number" value={ep.episodeNumber ?? ''} min={0} style={{ width: 80 }}
|
||||
onChange={e => update(ep.id, { episodeNumber: e.target.value === '' ? null : Number(e.target.value) })} />
|
||||
<input type="text" aria-label="Episode title" placeholder="Episode title (optional)" value={ep.title} style={{ flex: 1, minWidth: 180 }}
|
||||
onChange={e => update(ep.id, { title: e.target.value })} />
|
||||
<input type="date" aria-label="Date published" value={ep.datePublished}
|
||||
onChange={e => update(ep.id, { datePublished: e.target.value })} />
|
||||
</div>
|
||||
<strong style={{ fontSize: '0.8rem' }}>Pre-publish</strong>
|
||||
{renderTasks(ep, 'pre')}
|
||||
<strong style={{ fontSize: '0.8rem' }}>Post-publish</strong>
|
||||
{renderTasks(ep, 'post')}
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeEpisode(ep.id)}>Delete episode</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
|
||||
<input type="number" min={1} max={50} value={bulk} onChange={e => setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
|
||||
<button type="button" className="btn-admin-add" onClick={addEpisodes}>Add episode(s) to Lane {lane}</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
{sorted.length === 0 && <p className="admin-stats-note">No episodes in this lane yet.</p>}
|
||||
{lane !== 'other' && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
|
||||
<input type="number" min={1} max={50} value={bulk} onChange={e => setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
|
||||
<button type="button" className="btn-admin-add" onClick={addEpisodes}>Add episode(s) to Lane {lane}</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user