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:
@@ -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" },
|
||||
};
|
||||
Reference in New Issue
Block a user