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
+77
View File
@@ -0,0 +1,77 @@
const TOKEN_KEY = "goldfish_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function isLoggedIn() {
return Boolean(getToken());
}
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const resp = await fetch(path, { ...options, headers });
let data = null;
const ct = resp.headers.get("content-type") || "";
if (ct.includes("application/json")) {
data = await resp.json();
}
if (!resp.ok) {
const detail = data?.detail;
const msg = typeof detail === "string" ? detail : detail?.msg || resp.statusText;
throw new Error(msg || "Request failed");
}
return data;
}
export const api = {
login(pin) {
return request("/api/auth/pin", { method: "POST", body: JSON.stringify({ pin }) });
},
listRecaps(limit = 20, offset = 0) {
return request(`/api/recaps?limit=${limit}&offset=${offset}`);
},
getRecap(videoId) {
return request(`/api/recaps/${encodeURIComponent(videoId)}`);
},
createRecap(url, force = false) {
return request("/api/recaps", { method: "POST", body: JSON.stringify({ url, force }) });
},
regenerateRecap(videoId) {
return request(`/api/recaps/${encodeURIComponent(videoId)}/regenerate`, { method: "POST" });
},
};
export function formatTimestamp(seconds) {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
export function youtubeUrl(videoId, seconds) {
const base = `https://www.youtube.com/watch?v=${videoId}`;
return seconds != null ? `${base}&t=${Math.floor(seconds)}s` : base;
}
export const VERDICT_LABELS = {
watch: { text: "Lohnt sich", emoji: "🎬", className: "verdict-watch" },
skim: { text: "Nur Stellen", emoji: "🐟", className: "verdict-skim" },
skip: { text: "Skip it", emoji: "💤", className: "verdict-skip" },
};