feat: add Goldfish Recap YouTube summary PWA

YouTube subtitles via OpenRouter become persistent, shareable recaps
with timestamp jump links, PIN-protected creation, and Traefik deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-06-16 15:49:24 +02:00
commit 3f1da92cf4
27 changed files with 2070 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
import {
api,
VERDICT_LABELS,
formatTimestamp,
youtubeUrl,
getToken,
setToken,
isLoggedIn,
} from "./api.js";
function getVideoIdFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] === "r" && parts[1]) return decodeURIComponent(parts[1]);
const params = new URLSearchParams(window.location.search);
return params.get("v") || "";
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function setMeta(recap) {
document.title = `${recap.title} — Goldfish Recap`;
document.getElementById("metaDescription").content = recap.tldr;
document.getElementById("ogTitle").content = recap.title;
document.getElementById("ogDescription").content = recap.tldr;
document.getElementById("ogImage").content = recap.thumbnail_url || "";
}
function renderRecap(recap) {
const verdict = VERDICT_LABELS[recap.watch_verdict] || VERDICT_LABELS.skim;
document.getElementById("verdictBadge").className = `verdictBadge ${verdict.className}`;
document.getElementById("verdictBadge").textContent = `${verdict.emoji} ${verdict.text}`;
document.getElementById("videoTitle").textContent = recap.title;
document.getElementById("channelName").textContent = recap.channel || "";
document.getElementById("thumbImg").src = recap.thumbnail_url || "";
document.getElementById("thumbImg").alt = recap.title;
document.getElementById("thumbLink").href = youtubeUrl(recap.video_id);
document.getElementById("tldrText").textContent = recap.tldr;
document.getElementById("vibeText").textContent = recap.vibe_check || "";
document.getElementById("goldfishNote").textContent = recap.goldfish_note || "";
const sections = recap.sections || [];
document.getElementById("sectionsList").innerHTML = sections
.map(
(s) => `
<a class="sectionCard" href="${youtubeUrl(recap.video_id, s.timestamp_seconds)}" target="_blank" rel="noopener">
<span class="sectionTime">${formatTimestamp(s.timestamp_seconds)}</span>
<span class="sectionEmoji">${escapeHtml(s.emoji || "🐟")}</span>
<div>
<h3>${escapeHtml(s.title)}</h3>
<p>${escapeHtml(s.summary)}</p>
</div>
</a>
`
)
.join("");
setMeta(recap);
}
function showError(msg) {
document.getElementById("loadingState").hidden = true;
document.getElementById("recapMain").hidden = true;
document.getElementById("errorState").hidden = false;
document.getElementById("errorText").textContent = msg;
}
async function loadRecap() {
const videoId = getVideoIdFromPath();
if (!videoId) {
showError("Keine Video-ID in der URL.");
return;
}
try {
const recap = await api.getRecap(videoId);
document.getElementById("loadingState").hidden = true;
document.getElementById("recapMain").hidden = false;
renderRecap(recap);
if (isLoggedIn()) {
document.getElementById("adminRow").hidden = false;
}
document.getElementById("copyLinkBtn").addEventListener("click", async () => {
const url = `${window.location.origin}/r/${encodeURIComponent(videoId)}`;
try {
await navigator.clipboard.writeText(url);
document.getElementById("copyLinkBtn").textContent = "Kopiert!";
setTimeout(() => {
document.getElementById("copyLinkBtn").textContent = "Link kopieren";
}, 2000);
} catch {
window.prompt("Link kopieren:", url);
}
});
} catch (err) {
showError(err.message || "Recap nicht gefunden.");
}
}
function wireRegenerate() {
document.getElementById("regenerateBtn").addEventListener("click", async () => {
const videoId = getVideoIdFromPath();
const btn = document.getElementById("regenerateBtn");
btn.disabled = true;
btn.textContent = "Generiere…";
try {
const result = await api.regenerateRecap(videoId);
renderRecap(result.recap);
} catch (err) {
alert(err.message || "Regenerieren fehlgeschlagen.");
} finally {
btn.disabled = false;
btn.textContent = "Neu generieren";
}
});
}
function wirePinModal() {
const pinModal = document.getElementById("pinModal");
document.getElementById("pinCancel").addEventListener("click", () => pinModal.close());
document.getElementById("pinForm").addEventListener("submit", async (e) => {
e.preventDefault();
const errEl = document.getElementById("pinError");
errEl.hidden = true;
try {
const { token } = await api.login(document.getElementById("pinInput").value.trim());
setToken(token);
pinModal.close();
document.getElementById("adminRow").hidden = false;
} catch {
errEl.textContent = "Falscher PIN.";
errEl.hidden = false;
}
});
}
wireRegenerate();
wirePinModal();
loadRecap();
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}