Files
nmemmert f14992350c adding
2026-05-18 08:27:24 -04:00

402 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 1115</div>`;
titus.forEach(ep => html += renderCard(ep));
}
if (colossians.length) {
html += `<div class="section-label">Colossians — Episodes 127</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>