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:
+165
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
api,
|
||||
VERDICT_LABELS,
|
||||
formatTimestamp,
|
||||
getToken,
|
||||
setToken,
|
||||
isLoggedIn,
|
||||
} from "./api.js";
|
||||
|
||||
const createModal = document.getElementById("createModal");
|
||||
const pinModal = document.getElementById("pinModal");
|
||||
const recapList = document.getElementById("recapList");
|
||||
const emptyState = document.getElementById("emptyState");
|
||||
const listError = document.getElementById("listError");
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderRecapCard(item) {
|
||||
const verdict = VERDICT_LABELS[item.watch_verdict] || VERDICT_LABELS.skim;
|
||||
const href = `/r/${encodeURIComponent(item.video_id)}`;
|
||||
return `
|
||||
<a class="recapCard" href="${href}">
|
||||
<img class="recapThumb" src="${escapeHtml(item.thumbnail_url || "")}" alt="" loading="lazy">
|
||||
<div class="recapCardBody">
|
||||
<span class="verdictBadge ${verdict.className}">${verdict.emoji} ${verdict.text}</span>
|
||||
<h3>${escapeHtml(item.title)}</h3>
|
||||
<p class="recapTldr">${escapeHtml(item.tldr)}</p>
|
||||
<span class="recapMeta">${escapeHtml(item.channel || "")}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadRecaps() {
|
||||
listError.hidden = true;
|
||||
try {
|
||||
const data = await api.listRecaps(30);
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
recapList.innerHTML = "";
|
||||
emptyState.hidden = false;
|
||||
return;
|
||||
}
|
||||
emptyState.hidden = true;
|
||||
recapList.innerHTML = items.map(renderRecapCard).join("");
|
||||
} catch (err) {
|
||||
listError.textContent = err.message || "Liste konnte nicht geladen werden.";
|
||||
listError.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPinModal() {
|
||||
document.getElementById("pinError").hidden = true;
|
||||
document.getElementById("pinInput").value = "";
|
||||
pinModal.showModal();
|
||||
document.getElementById("pinInput").focus();
|
||||
}
|
||||
|
||||
function waitForPinLogin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isLoggedIn()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
openPinModal();
|
||||
pinModal.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
if (isLoggedIn()) resolve();
|
||||
else reject(new Error("Abgebrochen"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById("createError").hidden = true;
|
||||
document.getElementById("createProgress").hidden = true;
|
||||
document.getElementById("urlInput").value = "";
|
||||
document.getElementById("forceCheckbox").checked = false;
|
||||
document.getElementById("createSubmit").disabled = false;
|
||||
createModal.showModal();
|
||||
document.getElementById("urlInput").focus();
|
||||
}
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
const url = document.getElementById("urlInput").value.trim();
|
||||
const force = document.getElementById("forceCheckbox").checked;
|
||||
const errEl = document.getElementById("createError");
|
||||
const progress = document.getElementById("createProgress");
|
||||
const submit = document.getElementById("createSubmit");
|
||||
|
||||
errEl.hidden = true;
|
||||
progress.hidden = false;
|
||||
submit.disabled = true;
|
||||
|
||||
try {
|
||||
const result = await api.createRecap(url, force);
|
||||
createModal.close();
|
||||
window.location.href = `/r/${encodeURIComponent(result.recap.video_id)}`;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || "Recap fehlgeschlagen.";
|
||||
errEl.hidden = false;
|
||||
progress.hidden = true;
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function wirePinForm() {
|
||||
document.getElementById("pinCancel").addEventListener("click", () => pinModal.close());
|
||||
document.getElementById("pinForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const pin = document.getElementById("pinInput").value.trim();
|
||||
const errEl = document.getElementById("pinError");
|
||||
errEl.hidden = true;
|
||||
try {
|
||||
const { token } = await api.login(pin);
|
||||
setToken(token);
|
||||
pinModal.close();
|
||||
} catch {
|
||||
errEl.textContent = "Falscher PIN. Dein Goldfisch auch.";
|
||||
errEl.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireCreate() {
|
||||
document.getElementById("newRecapBtn").addEventListener("click", async () => {
|
||||
try {
|
||||
await waitForPinLogin();
|
||||
openCreateModal();
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
});
|
||||
document.getElementById("createCancel").addEventListener("click", () => createModal.close());
|
||||
document.getElementById("createForm").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
try {
|
||||
await waitForPinLogin();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await handleCreate(e);
|
||||
});
|
||||
}
|
||||
|
||||
function registerSw() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
wirePinForm();
|
||||
wireCreate();
|
||||
loadRecaps();
|
||||
registerSw();
|
||||
Reference in New Issue
Block a user