402 lines
19 KiB
HTML
402 lines
19 KiB
HTML
<!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>
|