3f1da92cf4
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>
78 lines
2.3 KiB
JavaScript
78 lines
2.3 KiB
JavaScript
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" },
|
|
};
|