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:
+148
@@ -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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user