Files
recap/public/index.html
T
Keysat b4fa5d7be8 Add opt-in Daily Digest (daily email of last 24h of library recaps)
Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
2026-06-15 19:50:48 -05:00

12672 lines
595 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover so the app paints under iOS safe-areas
when installed as a PWA (status bar + home indicator). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Recaps</title>
<!-- ── PWA / Add-to-Home-Screen ──────────────────────────────────────
manifest.json drives Chrome/Edge/Firefox install. The apple-*
meta tags are iOS-specific (Safari ignores manifest for
"Add to Home Screen" behavior). theme-color matches the body
background so the system chrome blends with the app. -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a0e1a">
<link rel="apple-touch-icon" href="/assets/icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Recaps">
<meta name="mobile-web-app-capable" content="yes">
<!-- ── Social preview (Open Graph + Twitter Card) ──────────────────
Drives the link-preview card on iMessage, Twitter/X, LinkedIn,
Discord, Slack, etc. og:image must be an absolute URL — relative
paths get rejected by some scrapers. The 1024×1024 icon doubles
as the social image for now; replace with a 1200×630 social
card when we ship a real one. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="Recaps">
<meta property="og:title" content="Recaps — summarize any video or podcast">
<meta property="og:description" content="Paste a YouTube or podcast link, get topic-level summaries with timestamps. Free trial — no signup required.">
<meta property="og:url" content="https://recaps.cc/">
<meta property="og:image" content="https://recaps.cc/assets/icon.png">
<meta property="og:image:width" content="1024">
<meta property="og:image:height" content="1024">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Recaps — summarize any video or podcast">
<meta name="twitter:description" content="Paste a YouTube or podcast link, get topic-level summaries with timestamps. Free trial — no signup required.">
<meta name="twitter:image" content="https://recaps.cc/assets/icon.png">
<meta name="description" content="Summarize any YouTube video or podcast episode into topic-level summaries with timestamps. Paste a link, get a recap.">
<link rel="icon" type="image/png" href="/assets/icon.png">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0a0e1a;
min-height: 100vh;
color: #e2e8f0;
}
.container { max-width: 100%; margin: 0 auto; padding: 36px 24px; transition: margin-left 0.2s ease, max-width 0.2s ease; }
.container.has-results { padding: 16px 24px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Split-screen results layout */
.results-split {
display: flex; gap: 16px; flex: 1; min-height: 0;
}
.results-left {
flex: 0 0 58%; max-width: 58%; display: flex; flex-direction: column; gap: 10px;
}
.results-right {
flex: 1; display: flex; flex-direction: column; min-width: 0;
}
.results-right .stats-bar { flex-shrink: 0; }
.chunks-scroll {
flex: 1; overflow-y: auto; padding-right: 4px;
/* iOS Safari: keep momentum scrolling and stop scroll-chaining to
the locked page body, which could leave the transcript unable to
pull back to the top on mobile. */
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
.chunks-scroll::-webkit-scrollbar { width: 6px; }
.chunks-scroll::-webkit-scrollbar-track { background: transparent; }
.chunks-scroll::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
.chunks-scroll::-webkit-scrollbar-thumb:hover { background: #334155; }
/* Video sticks to top in split mode */
.results-left .video-embed {
margin-bottom: 0; flex-shrink: 0;
}
.results-left .video-title {
font-size: 15px; font-weight: 600; color: #e2e8f0; line-height: 1.4;
padding: 0 2px;
}
.results-left .video-meta {
font-size: 12px; color: #475569; padding: 0 2px;
}
/* Minimized video player bar */
.video-mini-bar {
display: none; height: 44px; align-items: center; gap: 10px;
padding: 0 14px; background: #111827; border: 1px solid #1e293b;
border-radius: 10px; cursor: pointer; transition: all 0.2s;
}
.video-mini-bar:hover { border-color: #334155; background: #1e293b; }
.video-mini-bar .mini-title {
flex: 1; font-size: 13px; font-weight: 500; color: #e2e8f0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.video-mini-bar .mini-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #334155;
background: none; color: #94a3b8; font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.video-mini-bar .mini-btn:hover { background: #334155; color: #e2e8f0; }
.results-left.minimized .video-embed { display: none; }
.results-left.minimized .video-title { display: none; }
.results-left.minimized .video-meta { display: none; }
.results-left.minimized .minimize-toggle { display: none; }
.results-left.minimized .video-mini-bar { display: flex; }
.results-left.minimized { flex: none !important; max-width: 100% !important; }
.minimize-toggle {
position: absolute; top: 8px; right: 8px; z-index: 5;
width: 30px; height: 30px; border-radius: 8px;
background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.1);
color: #fff; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); transition: all 0.15s; opacity: 0;
}
.results-left:hover .minimize-toggle { opacity: 1; }
.minimize-toggle:hover { background: rgba(0,0,0,0.8); }
/* Mobile: video pinned to top, chunks scroll below */
@media (max-width: 900px) {
.results-split {
flex-direction: column; height: calc(100vh - 160px);
}
.results-left {
flex: none; max-width: 100%; position: sticky; top: 0;
z-index: 10; background: #0a0e1a; padding-bottom: 8px;
}
.results-left .video-title { font-size: 13px; }
.results-left .video-meta { font-size: 11px; }
.results-right { flex: 1; min-height: 0; }
.chunks-scroll { flex: 1; overflow-y: auto; }
.container.has-results { max-width: 100%; padding: 12px 12px; }
.container.has-results .top-bar { margin-bottom: 12px; }
.top-bar-input { margin: 0 6px; }
/* Same iOS auto-zoom guard as the <=600px block — keep input
at 16px so Safari doesn't viewport-zoom on focus. */
.top-bar-input .url-input { padding: 7px 10px; font-size: 16px; }
.top-bar-input .submit-btn { padding: 7px 12px; font-size: 13px; }
}
/* Landscape on mobile: video goes fullscreen-ish */
@media (max-width: 900px) and (orientation: landscape) {
.results-split { height: 100vh; }
.results-left {
position: fixed; inset: 0; z-index: 100;
background: #000; display: flex; align-items: center;
justify-content: center; padding: 0;
}
.results-left .video-embed {
width: 100%; height: 100%; border-radius: 0;
border: none; aspect-ratio: auto;
}
.results-left .video-title,
.results-left .video-meta { display: none; }
/* Show a small back button to exit fullscreen */
.landscape-back {
position: fixed; top: 12px; left: 12px; z-index: 101;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(0,0,0,0.6); color: #fff; border: none;
font-size: 18px; cursor: pointer; display: flex;
align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.results-right { display: none; }
.container.has-results .top-bar,
.container.has-results .error-box,
.container.has-results .loading { display: none; }
}
/* Hide landscape-back in portrait / desktop */
@media (min-width: 901px), (orientation: portrait) {
.landscape-back { display: none !important; }
}
/* ── Mobile menu dropdown ────────────────────────────── */
.mobile-menu-btn { display: none; }
.mobile-menu-dropdown {
display: none; position: absolute; top: 100%; right: 0; z-index: 200;
background: #111827; border: 1px solid #1e293b; border-radius: 12px;
box-shadow: 0 12px 32px rgba(0,0,0,0.5); padding: 6px;
min-width: 180px; animation: slideUp 0.15s ease;
}
.mobile-menu-dropdown.open { display: block; }
.mobile-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 10px 14px; background: none; border: none; border-radius: 8px;
color: #94a3b8; font-size: 13px; font-weight: 500; cursor: pointer;
text-align: left; transition: all 0.15s; white-space: nowrap;
}
.mobile-menu-item:hover { background: #1e293b; color: #e2e8f0; }
.mobile-menu-item.active { color: #818cf8; }
.mobile-menu-item.danger { color: #f87171; }
.mobile-menu-item .menu-icon { width: 18px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mobile-menu-item .menu-badge {
margin-left: auto; min-width: 18px; height: 18px; border-radius: 9px;
background: #818cf8; color: #fff; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center; padding: 0 5px;
}
.mobile-menu-item .status-dot {
width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0;
}
.mobile-menu-sep { height: 1px; background: #1e293b; margin: 4px 8px; }
.mobile-menu-overlay {
display: none; position: fixed; inset: 0; z-index: 199;
}
.mobile-menu-overlay.open { display: block; }
/* ── Mobile top bar (≤600px) ─────────────────────────── */
@media (max-width: 600px) {
.container { padding: 12px 10px; }
.container.has-results { padding: 8px 8px; height: 100vh; height: 100dvh; }
/* Pin the top bar so it stays visible even if the body scrolls.
body has min-height: 100vh and no overflow lock, so a tall
result page (audio player + chunk list + topic detail) can
push the page taller than the viewport — without sticky,
the user scrolls down to see the result and can't get back
to the URL input + hamburger menu without scrolling all the
way up. position: sticky inside a flex column works fine in
every modern browser. Background opaque so chunks below
don't bleed through. */
.top-bar {
flex-wrap: nowrap; gap: 6px;
position: sticky;
top: 0;
z-index: 50;
background: #0a0e1a;
padding: 6px 0;
}
/* Library button stays visible on mobile (left of the URL input)
as a one-tap target. The same toggle is also available via the
hamburger menu's "Library" item — two paths to the same action
since this is the most-used control on the screen. Sized
smaller than the desktop 36px to leave more room for the URL
input + Summarize button + hamburger on a tight mobile row. */
.top-left-actions { order: 0; flex-shrink: 0; }
.top-left-actions .icon-btn { width: 40px; height: 40px; }
.top-bar-input {
order: 1; flex: 1; min-width: 0; margin: 0; gap: 6px;
}
/* Hide the desktop info button (the "What can I paste?" popover
lives in the hamburger menu on mobile to keep the input bar
from getting crowded). */
.top-bar-input .info-btn { display: none; }
/* font-size MUST be >=16px on iOS or Safari auto-zooms the
viewport when the input takes focus — that's what cuts off
the Summarize button and visually shrinks the input. The
input renders the same physical size; only the digit-size
changes slightly. The button matches for visual balance. */
.top-bar-input .url-input { padding: 10px 12px; font-size: 16px; }
/* Submit button on mobile — drop the word "Summarize" (or Queue
/ Subscribe) and show a right-arrow icon instead. Sized to
match the hamburger button on the right so the trio (library
icon, input, submit, hamburger) reads as a clean row of
equal-height controls instead of a fat purple pill overlapping
the hamburger. */
.top-bar-input .submit-btn {
padding: 0; width: 48px; height: 48px; flex-shrink: 0;
border-radius: 11px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.top-bar-input .submit-btn .submit-btn-text { display: none; }
.top-bar-input .submit-btn .submit-btn-icon { display: flex; }
/* Hide the desktop icon row + status pills (credits live in the
hamburger / Settings on mobile to keep the top bar uncluttered) */
.top-actions { display: none !important; }
.top-bar-status { display: none !important; }
/* Hamburger menu button — large enough to be unmissable. iOS
minimum touch target is 44pt; we go 48 so it doesn't feel
cramped next to the input/Summarize cluster. */
.mobile-menu-btn {
display: flex; order: 2; flex-shrink: 0;
width: 48px; height: 48px; border-radius: 11px; border: 1px solid #1e293b;
background: #111827; color: #94a3b8; font-size: 24px;
cursor: pointer; align-items: center; justify-content: center;
transition: all 0.15s; position: relative;
margin-left: 4px;
}
.mobile-menu-btn:hover { background: #1e293b; color: #e2e8f0; }
/* Mobile menu dropdown items — bigger touch targets too. */
.mobile-menu-dropdown {
min-width: 240px;
padding: 8px;
}
.mobile-menu-item {
padding: 14px 16px; font-size: 14px;
min-height: 48px;
}
.mobile-menu-item .menu-icon svg { width: 18px; height: 18px; }
.mobile-menu-btn .dot {
position: absolute; top: 5px; right: 5px; width: 7px; height: 7px;
border-radius: 50%; background: #fbbf24;
}
/* Queue cards — more compact, allow title wrapping */
.queue-section { margin-top: 8px; }
.queue-item { padding: 8px 10px; gap: 6px; flex-wrap: wrap; }
.queue-item .queue-title { font-size: 12px; min-width: 60%; }
.queue-item .queue-from { font-size: 9px; }
.queue-item .queue-url { font-size: 11px; }
.queue-approve, .queue-reject { width: 28px; height: 28px; }
/* Queue header */
.queue-label { font-size: 10px; }
.queue-approve-all { font-size: 10px !important; padding: 3px 8px !important; }
/* Icon buttons smaller on mobile */
.icon-btn { width: 36px; height: 36px; border-radius: 9px; }
/* Stats bar wraps better */
.stats-bar { gap: 4px; flex-wrap: wrap; }
.stats { gap: 6px; font-size: 11px; flex-wrap: wrap; }
.expand-btn { padding: 5px 10px; font-size: 11px; }
/* Chunk headers more compact */
.chunk-header { padding: 8px 10px; gap: 6px; }
.chunk-header .chunk-time { font-size: 10px; }
.chunk-header .chunk-title { font-size: 12px; }
.chunk-header .chunk-summary { font-size: 11px; }
/* Transcript lines */
.transcript-line { padding: 3px 10px; font-size: 12px; }
.transcript-line .ts-badge { font-size: 10px; min-width: 36px; }
/* Settings modal full-width */
.settings-modal { max-width: 96vw; border-radius: 12px; }
/* Video embed smaller on mobile */
.video-embed { border-radius: 8px; }
/* Results split adjustments */
.results-split { height: calc(100vh - 70px); height: calc(100dvh - 70px); }
/* Side panels: full-width on phones so main content doesn't bleed
through the edge (was 85vw / 92vw which left a visible strip). */
.history-sidebar { width: 100vw; max-width: 100vw; }
.log-drawer { width: 100vw; max-width: 100vw; }
/* Minimize-video button is hover-gated on desktop, but touch
devices don't reliably fire :hover — leaving the button
invisible on phones. Pin it to fully visible at mobile width
so users can collapse the YouTube embed and see more of the
transcript. */
.minimize-toggle { opacity: 1 !important; }
/* De-dupe stats: results-left's .video-meta already lists topics /
segments / total, and .stats-bar in results-right repeats it.
On phones the two columns stack, so the line shows twice — hide
the redundant left-column copy. */
.results-left .video-meta { display: none; }
}
/* Top bar with title + settings gear */
.top-bar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; position: relative;
}
.top-left-actions {
display: flex; align-items: center; gap: 8px;
}
.top-bar-input {
flex: 1; display: flex; gap: 8px; max-width: 600px; margin: 0 12px;
}
.top-bar-input .url-input {
padding: 9px 14px; font-size: 13px; border-radius: 8px;
}
.top-bar-input .submit-btn {
padding: 9px 18px; font-size: 13px; border-radius: 8px;
}
/* Submit-button label/icon swap. Desktop shows the text label
("Summarize" / "Queue" / "Subscribe"); the icon is hidden because
the button is wide enough for words. Mobile flips this: the
button becomes a square ~48px arrow that matches the hamburger
button next to it, freeing horizontal space for the URL input
and getting rid of the overlap Grant kept hitting. */
.submit-btn-icon { display: none; align-items: center; justify-content: center; }
.submit-btn-text { display: inline; }
/* "What can I paste?" info icon to the left of the URL input.
Same flat icon-button styling as the top toolbar buttons but
smaller so it sits flush with the input bar. */
.info-btn {
background: transparent;
border: 1px solid #1e293b;
color: #64748b;
width: 32px; height: 32px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
align-self: center;
}
.info-btn:hover { background: #1e293b; color: #cbd5e1; border-color: #334155; }
.info-btn:active { transform: scale(0.96); }
/* Brand-styled popover card anchored under the info icon. Same
dark-glass aesthetic as the settings modal — single source of
visual truth for "Recap surfaces this card-like thing as
information." Positioned absolutely against .top-bar-input;
use top: full-input-height + small gap so it never overlaps
the input itself. */
.formats-info-card {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(420px, calc(100vw - 24px));
background: #121828;
border: 1px solid #1f2942;
border-radius: 12px;
padding: 16px 18px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
z-index: 50;
font-size: 13px;
color: #cbd5e1;
line-height: 1.55;
animation: formatsInfoIn 0.14s ease-out;
}
@keyframes formatsInfoIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.formats-info-card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 10px;
color: #f5f9ff;
font-size: 14px;
}
.formats-info-close {
background: transparent; border: none; color: #64748b;
font-size: 22px; line-height: 1; cursor: pointer; padding: 0 6px;
transition: color 0.15s ease;
}
.formats-info-close:hover { color: #cbd5e1; }
.formats-info-list {
list-style: none; margin: 0; padding: 0;
}
.formats-info-list li {
padding: 8px 0;
border-bottom: 1px solid rgba(31, 41, 66, 0.6);
}
.formats-info-list li:last-child { border-bottom: none; }
.formats-info-list strong { color: #e2e8f0; font-weight: 600; }
/* Inline status + upgrade slot between the URL input and the right
icons. Wraps on narrow screens; hidden on mobile alongside
.top-actions. */
.top-bar-status {
display: flex; align-items: center; gap: 6px; margin-right: 8px;
flex-wrap: wrap;
}
/* Top-row pizza tracker — 4-stage breadcrumb shown while a job is
processing. Matches the relay operator dashboard's tracker style
(same colors, same active-stage pulse animation) so the visual
language carries across both apps. Hidden by default; the JSX
returns empty when state.streaming === false. Sits adjacent to
the toolbar pills (.top-bar-status) at the top of the page. */
.top-breadcrumb {
display: flex; align-items: center; gap: 0;
padding: 6px 12px;
background: rgba(30, 41, 59, 0.4);
border: 1px solid #1e293b;
border-radius: 8px;
margin-right: 8px;
white-space: nowrap;
}
@keyframes top-breadcrumb-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* The breadcrumb gets dual-rendered: an inline copy inside
.top-bar for desktop, and a hoisted-out copy as a sibling
below .top-bar for mobile (so iOS Safari's flex-wrap +
position:sticky combo can't hide it). Default hides the
mobile copy; tablet+phone widths flip which copy shows. */
.top-breadcrumb-mobile { display: none !important; }
@media (max-width: 880px) {
/* On tablet widths the inline-toolbar breadcrumb gets crowded
out. Hide it; the mobile copy below the toolbar takes over. */
.top-bar > .top-breadcrumb { display: none; }
.top-breadcrumb-mobile {
display: flex !important;
justify-content: center;
padding: 8px 12px;
margin: 0 0 10px;
}
}
@media (max-width: 600px) {
/* Phone-specific touch-ups for the mobile breadcrumb copy. */
.top-breadcrumb-mobile {
margin: 0 0 8px;
padding: 8px 10px;
}
.top-breadcrumb-mobile span { font-size: 11px !important; }
}
.top-bar-status .status-pill {
font-size: 11px; font-weight: 600; padding: 6px 10px;
border-radius: 8px; border: 1px solid transparent;
white-space: nowrap;
}
.top-bar-status .upgrade-btn {
background: #a855f7; color: #fff; border: none;
padding: 7px 12px; border-radius: 8px; text-decoration: none;
font-size: 11px; font-weight: 700; cursor: pointer;
white-space: nowrap;
}
.top-bar-status .upgrade-btn:hover { background: #c084fc; }
.top-bar-status .have-key-btn {
background: transparent; color: #94a3b8;
border: 1px solid #334155; padding: 6px 10px; border-radius: 8px;
cursor: pointer; font-size: 11px; font-weight: 600;
white-space: nowrap;
}
.top-bar-status .have-key-btn:hover { color: #cbd5e1; border-color: #475569; }
/* Tier badge — explicit visual confirmation of an active paid
license. Matches the Upgrade button's accent so users associate
the color (purple) with "premium tier"; MAX is brighter to
distinguish it from PRO. Compact, uppercase, slightly tracked
— reads as a status marker rather than a button. */
.top-bar-status .tier-badge {
display: inline-flex; align-items: center;
font-size: 10px; font-weight: 800;
letter-spacing: 0.08em; padding: 4px 9px;
border-radius: 6px; white-space: nowrap;
border: 1px solid;
}
.top-bar-status .tier-badge.tier-pro {
color: #c4b5fd; background: rgba(168, 85, 247, 0.14);
border-color: rgba(168, 85, 247, 0.45);
}
.top-bar-status .tier-badge.tier-max {
color: #fde68a; background: rgba(250, 204, 21, 0.16);
border-color: rgba(250, 204, 21, 0.50);
}
/* Top-right icon buttons */
.top-actions { display: flex; gap: 8px; justify-content: flex-end; }
.icon-btn {
width: 40px; height: 40px; border-radius: 10px; border: 1px solid #1e293b;
background: #111827; color: #64748b; font-size: 20px; position: relative;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.icon-btn:hover { background: #1e293b; color: #94a3b8; border-color: #334155; }
.icon-btn.active { background: #818cf8; color: #fff; border-color: transparent; }
.icon-btn .dot {
position: absolute; top: 6px; right: 6px; width: 8px; height: 8px;
border-radius: 50%; background: #fbbf24; display: none;
}
.icon-btn.needs-key .dot { display: block; }
.icon-btn.needs-attention .dot { display: block; background: #ef4444; }
.icon-btn .badge-count {
position: absolute; top: 4px; right: 4px; min-width: 16px; height: 16px;
border-radius: 8px; background: #818cf8; color: #fff; font-size: 9px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
padding: 0 4px;
}
/* Settings modal overlay */
.settings-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 1000; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.settings-modal {
background: #111827; border: 1px solid #1e293b; border-radius: 16px;
width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,0.5); animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { opacity:0; transform: translateY(12px); } to { opacity:1; transform: translateY(0); } }
.settings-modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px; border-bottom: 1px solid #1e293b;
position: sticky; top: 0; background: #111827; z-index: 1; border-radius: 16px 16px 0 0;
}
.settings-modal-header h2 { font-size: 16px; font-weight: 700; color: #e2e8f0; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid #1e293b;
background: none; color: #64748b; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.close-btn:hover { background: #1e293b; color: #e2e8f0; }
.settings-modal-body { padding: 20px 24px; }
/* Buy / upgrade modal — same overlay pattern as settings but
wider to fit 23 tier cards side by side on desktop. Collapses
to a single column on narrow viewports. */
.buy-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.65);
z-index: 1100; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(5px); animation: fadeIn 0.15s ease;
padding: 20px;
}
.buy-modal {
background: #0f172a; border: 1px solid #1e293b; border-radius: 16px;
width: 1000px; max-width: 100%; max-height: 90vh; overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: slideUp 0.2s ease;
}
.buy-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 28px; border-bottom: 1px solid #1e293b;
position: sticky; top: 0; background: #0f172a; z-index: 1; border-radius: 16px 16px 0 0;
}
.buy-header h2 { font-size: 18px; font-weight: 700; color: #e2e8f0; margin: 0; }
.buy-body { padding: 28px; }
.buy-loading { text-align: center; color: #94a3b8; padding: 40px 20px; font-size: 14px; }
.buy-error {
padding: 20px; background: rgba(220,38,38,0.08);
border: 1px solid rgba(220,38,38,0.30); border-radius: 10px;
color: #fca5a5; text-align: center;
}
.buy-retry-btn {
margin-top: 12px; background: #1e293b; color: #cbd5e1;
border: 1px solid #334155; padding: 8px 18px; border-radius: 8px;
cursor: pointer; font-size: 12px; font-weight: 600;
}
/* ── Buy-credits success view ─────────────────────────────────
Centered confirmation that lands once the relay reports
status:"settled". The .buy-success-burst is a 96px square
holding the checkmark + a radial sparkle burst. Each sparkle
uses its own --ang custom property to translate outward at
a different angle; the keyframes scale + fade them out so
the result reads as a single quick burst.
Pure CSS, no library — feels celebratory but stays light. */
.buy-success {
text-align: center; padding: 8px 8px 4px;
}
.buy-success-burst {
position: relative;
width: 96px; height: 96px;
margin: 8px auto 18px;
}
.buy-success-check {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 56px; font-weight: 800; color: #4ade80;
background: rgba(74, 222, 128, 0.12);
border: 2px solid rgba(74, 222, 128, 0.40);
border-radius: 50%;
animation: buy-success-pop 600ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
transform: scale(0);
z-index: 2;
}
.buy-sparkle {
position: absolute;
top: 50%; left: 50%;
font-size: 18px;
transform: translate(-50%, -50%);
opacity: 0;
animation: buy-sparkle-burst 900ms ease-out forwards;
animation-delay: 120ms;
pointer-events: none;
}
@keyframes buy-success-pop {
0% { transform: scale(0) rotate(-10deg); opacity: 0; }
60% { transform: scale(1.1) rotate(0deg); opacity: 1; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
@keyframes buy-sparkle-burst {
0% { transform: translate(-50%, -50%) rotate(var(--ang)) translateY(0) scale(0.4); opacity: 0; }
30% { transform: translate(-50%, -50%) rotate(var(--ang)) translateY(-30px) scale(1.0); opacity: 1; }
100% { transform: translate(-50%, -50%) rotate(var(--ang)) translateY(-70px) scale(0.8); opacity: 0; }
}
.buy-success-title {
margin: 0 0 4px;
font-size: 18px; font-weight: 700;
color: #f5f9ff;
}
.buy-success-sub {
margin: 0;
font-size: 13px; line-height: 1.55;
color: #94a3b8;
}
.buy-tier-grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.buy-tier {
background: #111827; border: 1px solid #1e293b; border-radius: 12px;
padding: 24px; display: flex; flex-direction: column; gap: 12px;
position: relative; transition: border-color 0.15s, transform 0.15s;
}
.buy-tier:hover { border-color: #334155; transform: translateY(-2px); }
.buy-tier-highlighted {
border: 2px solid #a855f7;
background: linear-gradient(180deg, rgba(168,85,247,0.10), #111827);
box-shadow: 0 12px 40px rgba(168,85,247,0.25);
transform: translateY(-4px);
}
.buy-tier-highlighted:hover {
transform: translateY(-6px);
}
.buy-tier-top {
display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;
}
.buy-tier-name {
font-size: 22px; font-weight: 700; color: #e2e8f0; letter-spacing: -0.01em;
}
.buy-tier-badges {
display: flex; flex-direction: column; gap: 6px; align-items: flex-end;
}
.buy-badge {
background: #a855f7; color: #fff;
padding: 4px 10px; border-radius: 999px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
}
.buy-discount-badge {
background: #16a34a; color: #fff;
padding: 4px 10px; border-radius: 999px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
}
.buy-tier-desc { font-size: 13px; color: #94a3b8; line-height: 1.5; }
.buy-price-row {
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
margin-top: 4px;
}
.buy-price-new { font-size: 28px; font-weight: 700; color: #e2e8f0; }
.buy-price-old {
font-size: 16px; color: #64748b; text-decoration: line-through;
}
.buy-price-unit { font-size: 13px; color: #94a3b8; font-weight: 500; }
.buy-trial {
font-size: 11px; color: #86efac; font-weight: 600;
background: rgba(134,239,172,0.08); border: 1px solid rgba(134,239,172,0.25);
padding: 4px 8px; border-radius: 6px; align-self: flex-start;
}
.buy-bullets {
list-style: none; padding: 0; margin: 8px 0 0; display: flex;
flex-direction: column; gap: 8px;
}
.buy-bullets li {
font-size: 13px; color: #cbd5e1; padding-left: 22px; position: relative;
line-height: 1.5;
}
.buy-bullets li::before {
content: "✓"; position: absolute; left: 0; top: 0;
color: #86efac; font-weight: 700;
}
/* Welcome modal bullets — same green-check style as .buy-bullets
but with a two-line layout (strong title + soft-color body
below it). Used only by renderWelcomeModal. */
.welcome-bullets {
list-style: none; padding: 0; margin: 0; display: flex;
flex-direction: column; gap: 14px;
}
.welcome-bullets li {
padding-left: 24px; position: relative; line-height: 1.5;
}
.welcome-bullets li::before {
content: "✓"; position: absolute; left: 0; top: 0;
color: #86efac; font-weight: 700; font-size: 14px;
}
.welcome-bullets li strong {
display: block; font-size: 14px; color: #e2e8f0; font-weight: 600;
margin-bottom: 2px;
}
.welcome-bullets li span {
display: block; font-size: 12.5px; color: #94a3b8; line-height: 1.5;
}
.buy-select-btn {
margin-top: auto; background: #1e293b; color: #e2e8f0;
border: 1px solid #334155; padding: 11px 16px; border-radius: 9px;
cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s;
}
.buy-select-btn:hover { background: #334155; border-color: #475569; }
.buy-select-btn-primary { background: #a855f7; border-color: #a855f7; color: #fff; }
.buy-select-btn-primary:hover { background: #c084fc; border-color: #c084fc; }
/* Self-serve subscription modal — "Pay with Bitcoin" primary pill
+ a muted "Pay by card" link beneath it. */
/* The "Pay with Bitcoin" pill inherits the standard purple from
.buy-select-btn-primary (matching every other primary pill); we only
add the glyph layout here. */
.sub-pay-btc {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
}
.sub-pay-btc:disabled { opacity: 0.6; cursor: default; }
.sub-btc-glyph { font-weight: 800; font-size: 15px; line-height: 1; }
.sub-pay-card {
margin-top: 8px; background: transparent; border: none; color: #94a3b8;
font-size: 13px; font-weight: 500; cursor: pointer; padding: 6px 4px;
text-decoration: underline; text-underline-offset: 3px;
align-self: center;
}
.sub-pay-card:hover { color: #cbd5e1; }
.sub-pay-card:disabled { opacity: 0.5; cursor: default; }
.sub-card-note {
margin-top: 12px; font-size: 12.5px; color: #fcd34d;
background: rgba(252,211,77,0.08); border: 1px solid rgba(252,211,77,0.22);
padding: 8px 12px; border-radius: 8px; text-align: center;
}
.sub-busy {
margin-top: 12px; font-size: 13px; color: #a5b4fc; text-align: center;
}
.sub-foot-hint {
margin-top: 16px; font-size: 12px; color: #64748b; line-height: 1.55;
text-align: center;
}
.buy-polling {
text-align: center; padding: 40px 30px; max-width: 480px; margin: 0 auto;
}
.buy-polling-spinner { font-size: 40px; margin-bottom: 12px; }
.buy-polling h3 { font-size: 18px; font-weight: 700; color: #e2e8f0; margin: 0 0 12px; }
.buy-polling p { font-size: 13px; color: #94a3b8; line-height: 1.6; margin: 0 0 24px; }
.buy-polling-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.buy-secondary-btn {
background: #1e293b; color: #cbd5e1; border: 1px solid #334155;
padding: 8px 16px; border-radius: 8px; cursor: pointer;
font-size: 12px; font-weight: 600;
}
.buy-secondary-btn:hover { background: #334155; }
.buy-poll-error {
margin-top: 16px; padding: 10px 14px; font-size: 11px; color: #fca5a5;
background: rgba(220,38,38,0.08); border: 1px solid rgba(220,38,38,0.25);
border-radius: 8px;
}
/* Discount-code interim view */
.buy-discount {
max-width: 480px; margin: 0 auto;
display: flex; flex-direction: column; gap: 16px;
}
.buy-back-link {
align-self: flex-start; background: transparent; border: none;
color: #94a3b8; cursor: pointer; font-size: 12px; font-weight: 600;
padding: 4px 0;
}
.buy-back-link:hover { color: #cbd5e1; }
.buy-discount-tier {
background: #111827; border: 1px solid #1e293b; border-radius: 10px;
padding: 16px;
}
.buy-discount-tier-name {
font-size: 18px; font-weight: 700; color: #e2e8f0; margin-bottom: 4px;
}
.buy-discount-form { display: flex; flex-direction: column; gap: 6px; }
.buy-discount-label {
font-size: 11px; color: #94a3b8; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
}
.buy-discount-input {
padding: 11px 14px; font-size: 14px; font-family: ui-monospace, monospace;
background: #0f172a; border: 1px solid #334155; border-radius: 9px;
color: #e2e8f0; text-transform: uppercase; letter-spacing: 0.05em;
}
.buy-discount-input:focus {
outline: none; border-color: #a855f7;
box-shadow: 0 0 0 3px rgba(168,85,247,0.15);
}
.buy-discount-input:disabled { opacity: 0.6; }
.buy-discount-actions {
display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px;
}
.buy-discount-actions .buy-select-btn { flex: 1; min-width: 200px; margin-top: 0; }
.buy-discount-actions .buy-secondary-btn { flex: 0 0 auto; }
.buy-discount-preview {
background: linear-gradient(180deg, rgba(134,239,172,0.08), #0f172a);
border: 1px solid rgba(134,239,172,0.30); border-radius: 10px;
padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
}
.buy-discount-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 13px; color: #cbd5e1;
}
.buy-discount-old { color: #64748b; text-decoration: line-through; }
.buy-discount-save { color: #86efac; font-weight: 600; }
.buy-discount-total {
margin-top: 6px; padding-top: 10px; border-top: 1px solid rgba(134,239,172,0.25);
font-size: 16px; font-weight: 700; color: #e2e8f0;
}
.buy-discount-hint {
font-size: 11px; color: #64748b; line-height: 1.5; margin-top: 4px;
}
/* Cards */
.card {
background: #111827;
border: 1px solid #1e293b;
border-radius: 14px;
margin-bottom: 14px;
overflow: hidden;
transition: border-color 0.2s;
}
.card:hover { border-color: #334155; }
.card-body { padding: 18px 22px; }
/* Model buttons */
.model-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.model-btn {
padding: 8px 16px; font-size: 12px; font-weight: 600;
border: 1px solid #1e293b; border-radius: 8px; cursor: pointer;
transition: all 0.2s; background: #0f172a; color: #64748b;
}
.model-btn:hover { border-color: #334155; color: #94a3b8; }
.model-btn.active {
background: #818cf8;
color: #fff; border-color: transparent;
}
.model-note { font-size: 11px; color: #475569; margin-top: 8px; font-style: italic; }
/* Input row */
.input-row { display: flex; gap: 10px; }
.url-input {
flex: 1; padding: 14px 18px; font-size: 15px;
border: 1px solid #334155; border-radius: 10px;
outline: none; transition: all 0.2s;
background: #1e293b; color: #f1f5f9;
}
.url-input::placeholder { color: #64748b; }
.url-input:focus { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.15); }
.submit-btn {
padding: 14px 30px; font-size: 15px; font-weight: 600;
background: #818cf8;
color: #fff; border: none; border-radius: 10px;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.submit-btn:disabled { background: #1e293b; color: #475569; cursor: not-allowed; }
.submit-btn:not(:disabled):hover { background: #a5b4fc; transform: translateY(-1px); }
/* API key */
.key-row { display: flex; gap: 8px; margin-bottom: 6px; }
.key-input {
flex: 1; padding: 10px 14px; font-size: 13px;
border: 1px solid #1e293b; border-radius: 8px; outline: none;
font-family: "SF Mono", Menlo, Consolas, monospace;
background: #0f172a; color: #94a3b8;
}
.key-input:focus { border-color: #818cf8; }
.key-toggle {
padding: 10px 14px; font-size: 12px; font-weight: 600;
background: #1e293b; color: #94a3b8; border: 1px solid #334155;
border-radius: 8px; cursor: pointer; white-space: nowrap;
}
.key-toggle:hover { background: #334155; }
.key-hint { font-size: 11px; color: #475569; margin-top: 4px; }
.field-label { font-size: 12px; font-weight: 600; color: #64748b; display: block; margin-bottom: 6px; margin-top: 16px; text-transform: uppercase; letter-spacing: 0.05em; }
.field-label:first-child { margin-top: 0; }
/* Status / Error */
.error-box {
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px;
padding: 16px; margin-bottom: 20px; color: #f87171; font-size: 14px;
line-height: 1.5; white-space: pre-wrap;
}
.loading { text-align: center; padding: 48px 20px; }
.spinner {
width: 40px; height: 40px;
border: 3px solid #1e293b; border-top-color: #818cf8;
border-radius: 50%; animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
.status-text { color: #94a3b8; font-size: 15px; font-weight: 500; animation: pulse 2s ease-in-out infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
/* Pipeline steps */
.pipeline { display: flex; gap: 8px; align-items: center; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
.step {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: #111827; border: 1px solid #1e293b; color: #475569; transition: all 0.3s;
}
.step.active { border-color: #818cf8; color: #a5b4fc; font-weight: 600; background: rgba(129,140,248,0.08); }
.step.done { border-color: #22c55e; color: #4ade80; background: rgba(34,197,94,0.08); }
.step-arrow { color: #334155; font-size: 16px; }
.step-icon { font-size: 16px; }
/* Video embed */
.video-embed {
border-radius: 14px; overflow: hidden; margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); aspect-ratio: 16/9;
border: 1px solid #1e293b;
}
.video-embed iframe { display: block; width: 100%; height: 100%; border: 0; }
/* Stats bar */
.stats-bar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px; flex-wrap: wrap; gap: 8px;
}
.stats { display: flex; gap: 12px; font-size: 12px; color: #64748b; }
.stats strong { color: #e2e8f0; }
.expand-btn {
padding: 7px 16px; font-size: 12px; font-weight: 600;
background: #1e293b; color: #94a3b8; border: 1px solid #334155;
border-radius: 8px; cursor: pointer; transition: all 0.15s;
}
.expand-btn:hover { background: #334155; color: #e2e8f0; }
/* Chunk cards — compact */
.chunk {
background: #111827; border: 1px solid #1e293b; border-radius: 10px;
margin-bottom: 6px; overflow: hidden; transition: all 0.2s;
}
.chunk:hover { border-color: #334155; }
.chunk.expanded { border-color: #818cf8; background: #0f172a; }
.chunk-header {
width: 100%; padding: 10px 14px; background: none; border: none;
cursor: pointer; text-align: left; display: flex; gap: 10px;
align-items: flex-start; color: inherit;
}
.chunk-play-btn {
width: 26px; height: 26px; border-radius: 7px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: #1e293b; color: #64748b; border: none; cursor: pointer;
transition: all 0.15s; font-size: 12px; margin-top: 1px;
}
.chunk-play-btn:hover { background: #818cf8; color: #fff; }
.chunk.now-playing .chunk-play-btn { background: #22c55e; color: #fff; }
.chunk-info { flex: 1; min-width: 0; }
.chunk-title-row { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.chunk-title {
font-size: 13px; font-weight: 650; color: #f1f5f9; line-height: 1.3;
}
.chunk-time {
font-size: 10px; color: #475569; font-weight: 500;
font-family: "SF Mono", Menlo, monospace; white-space: nowrap;
}
.chunk-summary {
margin: 3px 0 0; font-size: 12px; line-height: 1.5; color: #94a3b8;
}
.chunk-arrow {
font-size: 22px; color: #475569; transition: transform 0.2s; flex-shrink: 0;
padding: 4px 8px; border-radius: 6px; line-height: 1;
}
.chunk-arrow:hover { background: #1e293b; color: #818cf8; }
.chunk.expanded .chunk-arrow { transform: rotate(180deg); color: #818cf8; }
.chunk-body { max-height: 0; overflow: hidden; transition: max-height 0.4s ease; }
.chunk.expanded .chunk-body { max-height: 15000px; }
.chunk-body-inner {
padding: 0 14px 12px 52px; border-top: 1px solid #1e293b; padding-top: 12px;
}
.transcript-line {
display: flex; gap: 10px; align-items: flex-start; padding: 5px 8px;
cursor: pointer; border-radius: 6px; transition: background 0.15s;
border: none; background: none; width: 100%; text-align: left; color: inherit;
}
.transcript-line:hover { background: rgba(129,140,248,0.06); }
.ts-badge {
font-size: 11px; font-family: "SF Mono", Menlo, monospace; color: #818cf8;
min-width: 52px; padding-top: 2px;
font-weight: 500; white-space: nowrap; flex-shrink: 0;
}
.transcript-text { font-size: 13px; line-height: 1.55; color: #cbd5e1; }
/* Active transcript line (synced to playback) */
.transcript-line.active-line {
background: rgba(129,140,248,0.08); border-left: 2px solid #818cf8;
padding-left: 6px;
}
.transcript-line.active-line .ts-badge { color: #a5b4fc; font-weight: 600; }
.transcript-line.active-line .transcript-text { color: #f1f5f9; }
/* Speaker chip — small colored circle + letter shown beside each
transcript line that has a diarization-assigned speaker. The
chip color is determined by the speaker's index (Speaker_A =
chip-a, Speaker_B = chip-b, ...). Eight distinct colors cover
the realistic speaker-count range for podcasts/interviews; if
a video somehow has >8 speakers the extra ones cycle back. */
.speaker-chip {
display: inline-flex; align-items: center; justify-content: center;
min-width: 26px; height: 18px; padding: 0 6px;
font-size: 10px; font-weight: 700;
border-radius: 4px; flex-shrink: 0; margin-top: 1px;
letter-spacing: 0.02em; line-height: 1;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.speaker-chip.low-conf::after {
content: "?"; margin-left: 1px; opacity: 0.7; font-weight: 500;
}
.speaker-chip.chip-a { background: rgba(239,68,68,0.18); color: #fca5a5; border: 1px solid rgba(239,68,68,0.35); }
.speaker-chip.chip-b { background: rgba(59,130,246,0.18); color: #93c5fd; border: 1px solid rgba(59,130,246,0.35); }
.speaker-chip.chip-c { background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(34,197,94,0.35); }
.speaker-chip.chip-d { background: rgba(245,158,11,0.18); color: #fcd34d; border: 1px solid rgba(245,158,11,0.35); }
.speaker-chip.chip-e { background: rgba(168,85,247,0.18); color: #d8b4fe; border: 1px solid rgba(168,85,247,0.35); }
.speaker-chip.chip-f { background: rgba(14,165,233,0.18); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.35); }
.speaker-chip.chip-g { background: rgba(236,72,153,0.18); color: #f9a8d4; border: 1px solid rgba(236,72,153,0.35); }
.speaker-chip.chip-h { background: rgba(100,116,139,0.18); color: #cbd5e1; border: 1px solid rgba(100,116,139,0.35); }
/* Speaker legend — appears above the chunks list when diarization
has produced a speaker map. Each row: colored chip + speaker
name + stats (turns, total speaking time). Compact, never wraps
individual entries — wraps the whole row when narrow. */
.speakers-legend {
display: flex; flex-wrap: wrap; gap: 8px;
padding: 8px 12px; margin: 0 0 10px;
background: rgba(15,23,42,0.5);
border: 1px solid #1e293b; border-radius: 8px;
font-size: 11px; color: #cbd5e1;
}
.speakers-legend-title {
font-size: 10px; font-weight: 600; color: #64748b;
text-transform: uppercase; letter-spacing: 0.06em;
padding-top: 3px;
margin-right: 4px;
}
.speakers-legend-item {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px;
background: rgba(255,255,255,0.03);
border: 1px solid #1e293b; border-radius: 16px;
font-size: 11px;
}
.speakers-legend-item .legend-stats {
color: #64748b; font-size: 10px;
font-variant-numeric: tabular-nums;
}
/* Clip collection buttons */
.clip-line-btn {
opacity: 0; font-size: 12px; cursor: pointer; flex-shrink: 0;
padding: 2px 4px; border-radius: 4px; transition: opacity 0.15s;
}
.transcript-line:hover .clip-line-btn { opacity: 0.7; }
.chunk-header:hover .chunk-clip-btn { opacity: 0.7; }
.clip-line-btn:hover { opacity: 1 !important; background: rgba(129,140,248,0.15); }
/* Clip collection badge */
.clip-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600;
background: rgba(129,140,248,0.1); color: #a5b4fc; cursor: pointer;
border: 1px solid rgba(129,140,248,0.2); transition: all 0.15s;
}
.clip-badge:hover { background: rgba(129,140,248,0.2); }
/* yt-dlp status */
.ytdlp-status {
margin-top: 16px; padding: 12px 14px; border-radius: 8px;
font-size: 13px; display: flex; align-items: center;
justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.ytdlp-ok { background: rgba(34,197,94,0.08); color: #4ade80; border: 1px solid rgba(34,197,94,0.15); }
.ytdlp-warn { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.15); }
.ytdlp-err { background: rgba(239,68,68,0.08); color: #f87171; border: 1px solid rgba(239,68,68,0.15); }
.update-btn {
padding: 6px 14px; font-size: 12px; font-weight: 600;
border: none; border-radius: 6px; cursor: pointer;
background: #f59e0b; color: #0f172a; transition: all 0.15s;
}
.update-btn:hover { background: #fbbf24; }
.update-btn:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
/* Activity log entries (shared between drawer) */
.log-entry { display: flex; gap: 10px; }
.log-time { color: #334155; min-width: 52px; text-align: right; flex-shrink: 0; }
.log-msg { color: #64748b; }
.log-detail { color: #334155; }
.log-entry.error .log-msg { color: #f87171; }
.log-entry.done .log-msg { color: #4ade80; font-weight: 600; }
.log-entry.cost .log-msg { color: #fbbf24; }
/* Log drawer (slide-out panel) */
.log-drawer-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 1000; animation: fadeIn 0.15s ease;
}
.log-drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: 440px; max-width: 92vw;
background: #0a0e17; border-left: 1px solid #1e293b;
z-index: 1001; display: flex; flex-direction: column;
animation: slideIn 0.2s ease; box-shadow: -8px 0 32px rgba(0,0,0,0.4);
}
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.log-drawer-header {
padding: 16px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e293b; flex-shrink: 0;
}
.log-drawer-header h2 { font-size: 14px; font-weight: 700; color: #e2e8f0; }
.log-drawer-body {
flex: 1; overflow-y: auto; padding: 12px 16px;
font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace;
font-size: 12px; line-height: 1.8;
}
.log-drawer-body::-webkit-scrollbar { width: 6px; }
.log-drawer-body::-webkit-scrollbar-track { background: transparent; }
.log-drawer-body::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
.log-empty { color: #334155; text-align: center; padding: 40px 20px; font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
/* Glow effect on active elements */
.submit-btn:not(:disabled) { box-shadow: 0 4px 24px rgba(129,140,248,0.3); }
.chunk.expanded { box-shadow: 0 2px 16px rgba(129,140,248,0.06); }
/* Now-playing indicator */
.chunk.now-playing { border-color: #22c55e; }
/* ── Audio-first "Listen" player (Phase 3) ──────────────────────── */
.rp-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(5, 8, 16, 0.92); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.rp-overlay[hidden] { display: none; }
.rp-panel {
width: 100%; max-width: 460px; background: #0b1120;
border: 1px solid #1e293b; border-radius: 18px; padding: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5); display: flex; flex-direction: column; gap: 16px;
}
.rp-head { display: flex; align-items: center; gap: 10px; }
.rp-source-title { flex: 1; min-width: 0; font-size: 12px; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rp-close { background: transparent; border: none; color: #64748b; font-size: 20px;
cursor: pointer; line-height: 1; padding: 4px; }
.rp-close:hover { color: #e2e8f0; }
.rp-now { text-align: center; min-height: 150px; display: flex; flex-direction: column; gap: 8px; }
.rp-topic-counter { font-size: 12px; color: #818cf8; font-weight: 600; letter-spacing: 0.04em; }
.rp-topic-title { font-size: 20px; font-weight: 700; color: #f1f5f9; line-height: 1.25; }
.rp-status { font-size: 12px; color: #94a3b8; min-height: 14px; }
.rp-summary { font-size: 13px; color: #cbd5e1; line-height: 1.5; max-height: 160px;
overflow-y: auto; text-align: left; }
.rp-dots { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.rp-dot { width: 9px; height: 9px; border-radius: 50%; background: #1e293b; transition: background 0.2s; }
.rp-dot.ready { background: #334155; }
.rp-dot.failed { background: #7f1d1d; }
.rp-dot.active { background: #22c55e; transform: scale(1.25); }
.rp-deeper {
width: 100%; padding: 12px; border-radius: 12px; border: 1px solid #6366f1;
background: #4f46e5; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
}
.rp-deeper:hover { background: #4338ca; }
.rp-deeper.rp-resume { background: #0f172a; border-color: #334155; color: #e2e8f0; }
.rp-keep { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #94a3b8; cursor: pointer; }
.rp-keep input { accent-color: #818cf8; }
.rp-controls { display: flex; align-items: center; justify-content: center; gap: 18px; }
.rp-ctrl {
width: 52px; height: 52px; border-radius: 50%; border: 1px solid #1e293b;
background: #0f172a; color: #e2e8f0; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.rp-ctrl:hover { border-color: #334155; background: #1e293b; }
.rp-ctrl.rp-play { width: 64px; height: 64px; background: #22c55e; border-color: #22c55e; color: #04210f; font-size: 24px; }
.rp-ctrl.rp-play:hover { background: #16a34a; }
.rp-ctrl.rp-play svg { display: block; }
.rp-ctrl.rp-speed { width: 48px; height: 48px; font-size: 13px; font-weight: 700; }
.rp-listen-btn { color: #a5b4fc !important; border-color: #312e81 !important; }
/* Deep-dive source transcript (follow-along) */
.rp-transcript { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; text-align: left; padding: 2px 0; -webkit-overflow-scrolling: touch; }
.rp-transcript[hidden] { display: none; }
.rp-tline { display: block; width: 100%; text-align: left; background: transparent; border: none; color: #94a3b8; font-size: 12.5px; line-height: 1.5; padding: 6px 8px; border-radius: 6px; cursor: pointer; }
.rp-tline:hover { background: rgba(129,140,248,0.08); color: #cbd5e1; }
.rp-tline-active { background: rgba(34,197,94,0.14); color: #f1f5f9; }
.rp-tts { color: #64748b; font-variant-numeric: tabular-nums; margin-right: 6px; font-size: 11px; }
/* History sidebar — persistent, pushes content */
.history-sidebar {
position: fixed; top: 0; left: 0; bottom: 0; width: 320px;
background: #0a0e17; border-right: 1px solid #1e293b;
z-index: 50; display: flex; flex-direction: column;
}
.history-sidebar.animate-in {
animation: slideInLeft 0.2s ease;
}
.history-item { transition: opacity 0.2s, transform 0.2s, max-height 0.3s; max-height: 80px; overflow: hidden; }
.history-item.removing { opacity: 0; transform: translateX(-20px); max-height: 0; margin: 0; padding: 0; }
@keyframes slideInLeft { from { transform: translateX(-100%); } to { transform: translateX(0); } }
/* Push main content when sidebar is open (desktop) */
body.history-open .container { margin-left: 320px; max-width: calc(100% - 320px); }
body.history-open .container.has-results { margin-left: 320px; max-width: calc(100% - 320px); }
/* No overlay on desktop */
.history-sidebar-overlay { display: none; }
/* Mobile: revert to overlay behavior */
@media (max-width: 900px) {
.history-sidebar { max-width: 85vw; z-index: 1001; box-shadow: 8px 0 32px rgba(0,0,0,0.4); }
.history-sidebar-overlay {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 1000; animation: fadeIn 0.15s ease;
}
body.history-open .container,
body.history-open .container.has-results { margin-left: 0; max-width: 100%; }
}
.history-header {
padding: 14px 16px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e293b; flex-shrink: 0;
}
.history-header h2 { font-size: 14px; font-weight: 700; color: #e2e8f0; }
.history-actions { display: flex; gap: 6px; }
.history-action-btn {
width: 30px; height: 30px; border-radius: 7px; border: 1px solid #1e293b;
background: none; color: #64748b; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.history-action-btn:hover { background: #1e293b; color: #94a3b8; }
.history-list {
flex: 1; overflow-y: auto; padding: 8px;
}
.history-list::-webkit-scrollbar { width: 6px; }
.history-list::-webkit-scrollbar-track { background: transparent; }
.history-list::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
/* Folders */
.history-folder { margin-bottom: 4px; }
.history-folder-header {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-radius: 8px; cursor: pointer; border: none; background: none;
width: 100%; text-align: left; color: #94a3b8; font-size: 12px;
font-weight: 600; transition: background 0.15s;
}
.history-folder-header:hover { background: rgba(255,255,255,0.03); }
.folder-arrow { font-size: 10px; color: #475569; transition: transform 0.2s; width: 12px; text-align: center; }
.folder-arrow.open { transform: rotate(90deg); }
.folder-icon { font-size: 14px; }
.folder-name { flex: 1; }
.folder-count { color: #334155; font-size: 11px; font-weight: 500; }
.folder-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.history-folder-header:hover .folder-actions { opacity: 1; }
.folder-action {
width: 22px; height: 22px; border-radius: 4px; border: none;
background: none; color: #475569; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 11px;
}
.folder-action:hover { background: rgba(255,255,255,0.06); color: #94a3b8; }
.folder-action.danger:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.folder-items { padding-left: 12px; }
.folder-items.collapsed { display: none; }
.folder-drop-zone {
height: 2px; margin: 2px 0; border-radius: 1px; transition: all 0.15s;
}
.folder-drop-zone.drag-over { height: 4px; background: #818cf8; margin: 4px 0; }
/* History items */
.history-item {
display: flex; gap: 10px; align-items: center;
padding: 8px 10px; border-radius: 8px; cursor: grab;
transition: background 0.15s; border: 1px solid transparent; background: none;
width: 100%; text-align: left; color: inherit; font-size: 13px;
}
.history-item:hover { background: rgba(255,255,255,0.04); }
.history-item.active { background: rgba(129,140,248,0.1); border-color: rgba(129,140,248,0.2); }
.history-item.dragging { opacity: 0.3; }
.history-item.drop-above { box-shadow: 0 -2px 0 0 #818cf8; }
.history-item.drop-below { box-shadow: 0 2px 0 0 #818cf8; }
.history-thumb {
width: 44px; height: 33px; border-radius: 4px; flex-shrink: 0;
background: #1e293b; overflow: hidden;
}
.history-thumb img { width: 100%; height: 100%; object-fit: cover; }
.history-info { flex: 1; min-width: 0; }
.history-title {
font-size: 12px; font-weight: 600; color: #e2e8f0; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-meta { font-size: 10px; color: #475569; margin-top: 1px; }
.history-delete {
width: 24px; height: 24px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.history-item:hover .history-delete { opacity: 1; }
.history-delete:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.history-action-small {
width: 24px; height: 24px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.history-item:hover .history-action-small { opacity: 1; }
.history-action-small:hover { background: rgba(129,140,248,0.15); color: #818cf8; }
.history-section-label {
font-size: 10px; font-weight: 700; color: #334155; text-transform: uppercase;
letter-spacing: 0.06em; padding: 12px 10px 4px; }
.history-empty {
color: #334155; text-align: center; padding: 40px 20px; font-size: 13px;
}
/* Folder name edit */
.folder-name-input {
background: #0f172a; border: 1px solid #818cf8; border-radius: 4px;
color: #e2e8f0; font-size: 12px; font-weight: 600; padding: 2px 6px;
outline: none; width: 100%;
}
/* Session title edit */
.history-title-input {
background: #0f172a; border: 1px solid #818cf8; border-radius: 3px;
color: #e2e8f0; font-size: 12px; font-weight: 600; padding: 1px 4px;
outline: none; width: 100%;
}
/* Queue */
.queue-section { margin-top: 10px; }
.queue-item {
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
margin-bottom: 6px; font-size: 13px; color: #94a3b8; transition: all 0.15s;
}
.queue-item.processing { border-color: #818cf8; background: rgba(129,140,248,0.06); }
.queue-item .queue-pos {
width: 22px; height: 22px; border-radius: 6px; background: #1e293b;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: #475569; flex-shrink: 0;
}
.queue-item.processing .queue-pos { background: #818cf8; color: #fff; }
.queue-item .queue-url {
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; font-family: "SF Mono", Menlo, monospace; font-size: 12px;
}
.queue-item .queue-status { font-size: 11px; color: #475569; flex-shrink: 0; }
.queue-item.processing .queue-status { color: #a5b4fc; }
.queue-remove {
width: 22px; height: 22px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.queue-item:hover .queue-remove { opacity: 1; }
.queue-item.processing .queue-remove { display: none; }
.queue-remove:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.queue-label { font-size: 11px; color: #475569; font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.queue-item .queue-from {
font-size: 10px; color: #818cf8; background: rgba(129,140,248,0.1);
padding: 1px 6px; border-radius: 4px; flex-shrink: 0; white-space: nowrap;
}
.queue-item.pending-approval { border-color: #334155; border-style: dashed; background: rgba(129,140,248,0.03); }
.queue-item .queue-title {
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; font-size: 12px; color: #cbd5e1;
}
.queue-item .queue-actions { display: flex; gap: 4px; flex-shrink: 0; }
.queue-approve, .queue-reject {
width: 26px; height: 26px; border-radius: 6px; border: none;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s; font-size: 14px;
}
.queue-approve { background: rgba(34,197,94,0.12); color: #4ade80; }
.queue-approve:hover { background: rgba(34,197,94,0.25); color: #86efac; }
.queue-reject { background: rgba(239,68,68,0.1); color: #f87171; }
.queue-reject:hover { background: rgba(239,68,68,0.2); color: #fca5a5; }
.queue-approve-all {
font-size: 10px; color: #818cf8; background: rgba(129,140,248,0.1);
border: none; border-radius: 4px; padding: 2px 8px; cursor: pointer;
transition: all 0.15s; font-weight: 600; letter-spacing: 0.02em;
}
.queue-approve-all:hover { background: rgba(129,140,248,0.2); color: #a5b4fc; }
/* Toast notifications */
.toast-container {
position: fixed; top: 16px; right: 16px; z-index: 9999;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
padding: 10px 16px; color: #e2e8f0; font-size: 13px;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
animation: toastIn 0.25s ease;
pointer-events: auto; max-width: 340px;
}
.toast.fade-out { animation: toastOut 0.3s ease forwards; }
.toast-icon { font-size: 16px; flex-shrink: 0; }
.toast-msg { flex: 1; line-height: 1.3; }
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
/* Subscriptions */
.sub-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid #1e293b; }
.sub-add-row { display: flex; gap: 8px; margin-bottom: 10px; }
.sub-add-row input {
flex: 1; padding: 9px 12px; font-size: 12px;
border: 1px solid #1e293b; border-radius: 8px; outline: none;
background: #0f172a; color: #e2e8f0;
}
.sub-add-row input:focus { border-color: #818cf8; }
.sub-add-row input::placeholder { color: #475569; }
.sub-add-btn {
padding: 9px 16px; font-size: 12px; font-weight: 600;
background: #818cf8;
color: #fff; border: none; border-radius: 8px; cursor: pointer;
white-space: nowrap; transition: all 0.15s;
}
.sub-add-btn:disabled { background: #1e293b; color: #475569; cursor: not-allowed; }
.sub-add-btn:not(:disabled):hover { background: #a5b4fc; }
.sub-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
margin-bottom: 6px; transition: all 0.15s;
}
.sub-item.paused { opacity: 0.5; }
.sub-icon { font-size: 16px; flex-shrink: 0; }
.sub-info { flex: 1; min-width: 0; }
.sub-name { font-size: 13px; font-weight: 600; color: #e2e8f0; }
.sub-meta { font-size: 10px; color: #475569; margin-top: 1px; position: relative; }
.sub-actions { display: flex; gap: 4px; }
.sub-action {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #1e293b;
background: none; color: #64748b; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; font-size: 13px;
}
.sub-action:hover { background: #1e293b; color: #94a3b8; }
.sub-action.danger:hover { background: rgba(239,68,68,0.15); color: #f87171; border-color: rgba(239,68,68,0.3); }
.sub-empty { font-size: 12px; color: #334155; text-align: center; padding: 16px; }
/* Server status indicator */
.server-status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 10px; font-weight: 600; padding: 3px 10px;
border-radius: 6px; letter-spacing: 0.03em;
cursor: default; transition: all 0.3s;
}
.server-status .status-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
transition: background 0.3s;
}
.server-status.connected {
background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2);
}
.server-status.connected .status-dot { background: #4ade80; }
.server-status.sleeping {
background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.2);
}
.server-status.sleeping .status-dot { background: #fbbf24; }
.server-status.disconnected {
background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2);
}
.server-status.disconnected .status-dot { background: #f87171; }
@keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
/* Skeleton loading cards */
.skeleton-chunk {
background: #111827; border: 1px solid #1e293b; border-radius: 10px;
margin-bottom: 6px; padding: 12px 14px; overflow: hidden;
}
.skeleton-line {
height: 12px; border-radius: 4px; background: #1e293b; margin-bottom: 8px;
position: relative; overflow: hidden;
}
.skeleton-line::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.03), transparent);
animation: shimmer 1.8s ease-in-out infinite;
}
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.skeleton-line.title { width: 70%; height: 14px; }
.skeleton-line.subtitle { width: 90%; }
.skeleton-line.short { width: 40%; }
.loading-status-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
padding: 10px 14px; background: #111827; border: 1px solid #1e293b;
border-radius: 10px;
}
.loading-status-bar .spinner-sm {
width: 18px; height: 18px; border: 2px solid #1e293b; border-top-color: #818cf8;
border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
}
.loading-status-bar .status-msg { font-size: 12px; color: #94a3b8; font-weight: 500; }
.server-status.sleeping .status-dot { animation: pulse-dot 2s ease-in-out infinite; }
/* ── Keysat activation + license UI ───────────────────────────── */
.activation-screen {
position: fixed; inset: 0; z-index: 9999;
background: radial-gradient(ellipse at top, #1e293b 0%, #0a0e1a 60%);
display: flex; align-items: center; justify-content: center;
padding: 24px; overflow-y: auto;
}
.activation-card {
width: 100%; max-width: 480px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 14px;
padding: 32px 28px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.activation-card h1 { font-size: 22px; font-weight: 700; color: #e2e8f0; margin-bottom: 6px; }
.activation-card .activation-sub { font-size: 13px; color: #94a3b8; line-height: 1.5; margin-bottom: 22px; }
.activation-card .activation-label { display: block; font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
.activation-card textarea.activation-key {
width: 100%; min-height: 88px; padding: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px;
background: #020617; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px;
outline: none; resize: vertical; line-height: 1.45;
}
.activation-card textarea.activation-key:focus { border-color: #6366f1; }
.activation-card .activation-error {
margin-top: 10px; padding: 10px 12px;
background: rgba(220, 38, 38, 0.1); border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px; color: #fca5a5; font-size: 12px; line-height: 1.4;
}
.activation-card .activation-actions {
display: flex; gap: 10px; margin-top: 16px; align-items: center;
}
.activation-card .activation-btn {
flex: 1; padding: 11px 18px; font-size: 13px; font-weight: 600;
background: #6366f1; color: #fff; border: none; border-radius: 8px; cursor: pointer;
transition: background 0.15s;
}
.activation-card .activation-btn:hover:not(:disabled) { background: #4f46e5; }
.activation-card .activation-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.activation-card .activation-link {
font-size: 12px; color: #818cf8; text-decoration: none; padding: 8px 4px;
}
.activation-card .activation-link:hover { color: #a5b4fc; text-decoration: underline; }
.activation-card .activation-meta {
margin-top: 24px; padding-top: 18px; border-top: 1px solid #1e293b;
font-size: 11px; color: #64748b; line-height: 1.5;
}
/* In-settings license block + Pro upsell tiles */
.license-block {
padding: 14px; border: 1px solid #334155; border-radius: 10px;
background: rgba(99, 102, 241, 0.06); margin-bottom: 14px;
}
.license-block .lic-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.license-block .lic-tier {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
padding: 3px 8px; border-radius: 999px;
}
.license-block .lic-tier.core { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.4); }
.license-block .lic-tier.pro { background: rgba(168, 85, 247, 0.15); color: #d8b4fe; border: 1px solid rgba(168, 85, 247, 0.4); }
.license-block .lic-tier.unlicensed { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border: 1px solid #334155; }
.license-block .lic-meta { font-size: 11px; color: #64748b; margin-top: 8px; line-height: 1.5; }
.license-block .lic-meta .lic-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #94a3b8; }
.license-block .lic-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.license-block .lic-btn {
font-size: 11px; font-weight: 600; padding: 6px 12px; border-radius: 6px;
border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer;
text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
}
.license-block .lic-btn:hover { background: #334155; color: #e2e8f0; }
.license-block .lic-btn.danger { color: #fca5a5; border-color: rgba(220, 38, 38, 0.4); }
.license-block .lic-btn.danger:hover { background: rgba(220, 38, 38, 0.1); }
.pro-upsell {
padding: 14px; border: 1px dashed #334155; border-radius: 10px;
background: rgba(168, 85, 247, 0.05); margin: 8px 0;
}
.pro-upsell .pro-title { font-size: 13px; font-weight: 700; color: #d8b4fe; display: flex; align-items: center; gap: 6px; }
.pro-upsell .pro-desc { font-size: 11.5px; color: #94a3b8; margin-top: 6px; line-height: 1.5; }
.pro-upsell .pro-cta {
display: inline-block; margin-top: 10px;
padding: 6px 12px; font-size: 11px; font-weight: 600;
background: #a855f7; color: #fff; border-radius: 6px; text-decoration: none;
}
.pro-upsell .pro-cta:hover { background: #9333ea; }
</style>
</head>
<body class="history-open">
<div class="container" id="app"></div>
<div class="toast-container" id="toast-container"></div>
<!-- YouTube IFrame API -->
<script src="https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<script src="https://www.youtube.com/iframe_api"></script>
<!-- QR encoder for inline Lightning invoice rendering. Vendored
locally so the inline-payment UI works without a network round
trip to a CDN (and works behind start-tunnel without external
internet). Used only on the buy-credits modal — null-checked
so a load failure degrades to text-only BOLT11 + copy. -->
<script src="/assets/qrcode.min.js"></script>
<script>
// ── YouTube Player API ──────────────────────────────────────────────────
let ytPlayer = null;
let ytReady = false;
let ytCurrentVideoId = null;
let ytSavedTime = 0;
let ytWasPlaying = false;
function onYouTubeIframeAPIReady() {
ytReady = true;
}
function initPlayer(videoId) {
// If same video, just restore — don't recreate
if (ytCurrentVideoId === videoId && document.getElementById("yt-player")) {
// Player div was recreated by render(), need to rebuild but preserve time
const savedTime = ytSavedTime;
const wasPlaying = ytWasPlaying;
if (ytPlayer) { try { ytPlayer.destroy(); } catch {} ytPlayer = null; }
ytPlayer = new YT.Player("yt-player", {
videoId: videoId,
playerVars: { autoplay: 0, modestbranding: 1, rel: 0 },
events: {
onReady: () => {
if (savedTime > 0) {
ytPlayer.seekTo(savedTime, true);
}
// Only resume playback if the video was actually playing before render
if (wasPlaying) {
ytPlayer.playVideo();
} else {
// Explicitly pause to counteract any iframe auto-play behavior
ytPlayer.pauseVideo();
setTimeout(() => { try { ytPlayer.pauseVideo(); } catch {} }, 200);
setTimeout(() => { try { ytPlayer.pauseVideo(); } catch {} }, 500);
}
},
onStateChange: (e) => onPlayerStateChange(e),
},
});
return;
}
// New video
ytCurrentVideoId = videoId;
ytSavedTime = 0;
ytWasPlaying = false;
if (ytPlayer) { try { ytPlayer.destroy(); } catch {} ytPlayer = null; }
ytPlayer = new YT.Player("yt-player", {
videoId: videoId,
playerVars: { autoplay: 0, modestbranding: 1, rel: 0 },
events: {
onReady: () => {},
onStateChange: (e) => onPlayerStateChange(e),
},
});
}
// Save player state before render wipes the DOM
let savedLastSeekTarget = null;
function savePlayerState() {
if (ytPlayer && ytPlayer.getCurrentTime) {
try {
ytSavedTime = ytPlayer.getCurrentTime();
ytWasPlaying = ytPlayer.getPlayerState() === YT.PlayerState.PLAYING;
} catch {}
}
// Preserve lastSeekTarget across renders
savedLastSeekTarget = lastSeekTarget;
// Pause sync during render (DOM will be wiped)
lastHighlightedLine = null;
}
let lastSeekTarget = null;
function seekTo(seconds) {
// Auto-expand video if minimized when user clicks a transcript segment
if (state.videoMinimized) {
state.videoMinimized = false;
const left = document.querySelector(".results-left");
if (state.currentType !== "podcast" && left) {
// YouTube: un-minimize via CSS. The player is usually still
// mounted, so fall through and seek immediately. If a background
// render() dropped it while hidden, mount it now and seek once
// it's ready.
left.classList.remove("minimized");
const ytDiv = document.getElementById("yt-player");
if (ytDiv && !ytDiv.querySelector("iframe")) {
ensureYtMounted();
setTimeout(() => seekTo(seconds), 300);
return;
}
} else {
// Podcast: render() rebuilds the <audio> element; seek after.
render();
setTimeout(() => seekTo(seconds), 200);
return;
}
}
const sameSegment = (lastSeekTarget === seconds);
if (state.currentType === "podcast") {
const audio = document.getElementById("podcast-audio");
if (audio) {
if (sameSegment) {
// Toggle pause/resume without re-seeking
if (!audio.paused) {
audio.pause();
} else {
audio.play().catch(() => {});
startPodcastSync();
}
} else {
// New segment — seek and play
audio.currentTime = seconds;
audio.play().catch(() => {});
startPodcastSync();
lastSeekTarget = seconds;
}
}
return;
}
if (ytPlayer && ytPlayer.seekTo) {
if (sameSegment) {
// Toggle pause/resume without re-seeking
if (ytPlayer.getPlayerState() === YT.PlayerState.PLAYING) {
ytPlayer.pauseVideo();
} else {
ytPlayer.playVideo();
}
} else {
// New segment — seek and play
ytPlayer.seekTo(seconds, true);
ytPlayer.playVideo();
lastSeekTarget = seconds;
}
}
}
// Mount the YT player into #yt-player when the div is present, visible,
// and missing its iframe. Centralizes the "never create the player in a
// hidden container" rule shared by render() and the expand paths.
function ensureYtMounted() {
if (!state.videoId || !ytReady || state.videoMinimized) return;
const ytDiv = document.getElementById("yt-player");
if (ytDiv && !ytDiv.querySelector("iframe")) initPlayer(state.videoId);
}
function toggleVideoMinimize() {
state.videoMinimized = !state.videoMinimized;
// YouTube split view: minimize is purely visual — toggle the CSS
// class in place so the iframe (and any in-progress playback)
// survives. A full render() rebuilt the embed and re-created the
// YT player inside a display:none container, which wedged the
// IFrame API → a black frame that only a page reload could clear.
// The podcast view has no such class (it adds/removes the <audio>
// element and resizes the list), so it still goes through render().
const left = document.querySelector(".results-left");
if (state.currentType !== "podcast" && left) {
left.classList.toggle("minimized", state.videoMinimized);
// If a background render() dropped the iframe while we were hidden,
// mount it now that we're visible so expand never shows black.
if (!state.videoMinimized) ensureYtMounted();
} else {
render();
}
}
function togglePlayPause() {
if (state.currentType === "podcast") {
const audio = document.getElementById("podcast-audio");
if (audio) { audio.paused ? audio.play().catch(() => {}) : audio.pause(); }
return;
}
if (ytPlayer && ytPlayer.getPlayerState) {
if (ytPlayer.getPlayerState() === YT.PlayerState.PLAYING) {
ytPlayer.pauseVideo();
} else {
ytPlayer.playVideo();
}
}
}
function onPlayerStateChange(event) {
if (event.data === YT.PlayerState.PLAYING) {
startTranscriptSync();
} else {
stopTranscriptSync();
}
}
// ── Transcript highlight sync ────────────────────────────────────────────
let syncInterval = null;
let lastHighlightedLine = null;
function startTranscriptSync() {
if (syncInterval) return;
syncInterval = setInterval(syncTranscriptHighlight, 250);
}
function stopTranscriptSync() {
if (syncInterval) { clearInterval(syncInterval); syncInterval = null; }
clearTranscriptHighlight();
}
function syncTranscriptHighlight() {
if (!ytPlayer || !ytPlayer.getCurrentTime || !ytPlayer.getPlayerState) return;
try {
const playerState = ytPlayer.getPlayerState();
if (playerState !== YT.PlayerState.PLAYING) return;
const currentTime = ytPlayer.getCurrentTime();
// Find which transcript line matches
const lines = document.querySelectorAll(".transcript-line[data-offset]");
let activeLine = null;
for (const line of lines) {
const offset = parseFloat(line.dataset.offset);
if (offset <= currentTime) activeLine = line;
else break;
}
if (activeLine === lastHighlightedLine) return;
// Remove old highlight
if (lastHighlightedLine) lastHighlightedLine.classList.remove("active-line");
// Add new highlight
if (activeLine) {
activeLine.classList.add("active-line");
// Scroll the line into view within the chunk body
activeLine.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
lastHighlightedLine = activeLine;
// Also highlight the parent chunk in the now-playing style
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
if (activeLine) {
const chunk = activeLine.closest(".chunk");
if (chunk) chunk.classList.add("now-playing");
}
} catch {}
}
function clearTranscriptHighlight() {
if (lastHighlightedLine) {
lastHighlightedLine.classList.remove("active-line");
lastHighlightedLine = null;
}
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
}
// ── Podcast audio player sync ───────────────────────────────────────────
let podcastSyncInterval = null;
function startPodcastSync() {
if (podcastSyncInterval) return;
podcastSyncInterval = setInterval(syncPodcastHighlight, 250);
}
function stopPodcastSync() {
if (podcastSyncInterval) { clearInterval(podcastSyncInterval); podcastSyncInterval = null; }
clearTranscriptHighlight();
}
function syncPodcastHighlight() {
const audio = document.getElementById("podcast-audio");
if (!audio || audio.paused) return;
const currentTime = audio.currentTime;
const lines = document.querySelectorAll(".transcript-line[data-offset]");
let activeLine = null;
for (const line of lines) {
const offset = parseFloat(line.dataset.offset);
if (offset <= currentTime) activeLine = line;
else break;
}
if (activeLine === lastHighlightedLine) return;
if (lastHighlightedLine) lastHighlightedLine.classList.remove("active-line");
if (activeLine) {
activeLine.classList.add("active-line");
activeLine.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
lastHighlightedLine = activeLine;
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
if (activeLine) {
const chunk = activeLine.closest(".chunk");
if (chunk) chunk.classList.add("now-playing");
}
}
function initPodcastPlayer() {
const audio = document.getElementById("podcast-audio");
// Idempotent: a node preserved live across a render() (see render's
// audio re-attach) keeps its listeners, so re-running here would
// double them up — startPodcastSync would fire twice per play.
if (!audio || audio.dataset.inited) return;
audio.dataset.inited = "1";
audio.addEventListener("play", startPodcastSync);
audio.addEventListener("pause", stopPodcastSync);
audio.addEventListener("ended", stopPodcastSync);
}
// ── Provider catalog ─────────────────────────────────────────────────────
// Static metadata about each backend Recap can talk to. Drives the
// picker UI: which providers appear in each dropdown, which models
// each lists by default, and which key/URL fields the Settings panel
// shows. Server-side adapter behavior is independently authoritative
// — this catalog is purely UX scaffolding.
const PROVIDERS = [
{
id: "gemini",
name: "Google Gemini",
canTranscribe: true,
canAnalyze: true,
// Transcription uses Flash tier (best speed/cost on long audio).
// Older Flash generations are included so users on rate-limited
// or quota-restricted keys can fall back manually when 3-flash
// overloads. Order = newest first; the server's fallback chain
// walks the same list automatically if a 503 is returned.
transcriptionModels: [
"gemini-3-flash-preview",
"gemini-2.5-flash",
"gemini-3.1-flash-lite",
"gemini-2.5-pro",
"gemini-3.1-pro-preview",
],
analysisModels: [
"gemini-3.1-pro-preview",
"gemini-2.5-pro",
"gemini-3-flash-preview",
"gemini-2.5-flash",
"gemini-3.1-flash-lite",
],
keyField: { key: "apiKey", label: "Gemini API Key", placeholder: "AIza...", masked: true, helpUrl: "https://aistudio.google.com/apikey" },
},
{
id: "anthropic",
name: "Anthropic (Claude)",
canTranscribe: false,
canAnalyze: true,
transcriptionModels: [],
analysisModels: [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
],
keyField: { key: "apiKey", label: "Anthropic API Key", placeholder: "sk-ant-...", masked: true, helpUrl: "https://console.anthropic.com" },
},
{
id: "openai",
name: "OpenAI",
canTranscribe: true,
canAnalyze: true,
transcriptionModels: ["whisper-1"],
analysisModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o3-mini"],
keyField: { key: "apiKey", label: "OpenAI API Key", placeholder: "sk-...", masked: true, helpUrl: "https://platform.openai.com/api-keys" },
},
{
id: "whisper",
name: "OpenAI/Whisper-Compatible Endpoint",
canTranscribe: true,
canAnalyze: false,
// No fixed catalog — different Whisper backends use different
// model names (whisper-1 / whisper-large-v3 / base.en / etc.),
// so users define their model list in the credentials block.
transcriptionModels: [],
analysisModels: [],
urlField: { key: "baseURL", label: "Base URL", placeholder: "http://whisper.startos:8000" },
keyField: { key: "apiKey", label: "API Key (optional)", placeholder: "(leave blank if self-hosted with no auth)", masked: true },
modelsField: {
key: "models",
label: "Models",
placeholder: "whisper-1, whisper-large-v3",
hint: "Comma-separated. Common values: whisper-1 (OpenAI standard), whisper-large-v3 (whisper.cpp / faster-whisper / Groq).",
},
},
{
id: "openai-compatible",
name: "OpenAI-Compatible (DeepSeek, Together, Groq, …)",
canTranscribe: false,
canAnalyze: true,
transcriptionModels: [],
// No hardcoded catalog — the modelsField in credentials lets
// users define their model list, which then surfaces as the
// picker dropdown options. If the list is empty the picker
// falls back to a free-text input.
analysisModels: [],
analysisModelDefault: "deepseek-chat",
keyField: { key: "apiKey", label: "API Key", placeholder: "sk-...", masked: true },
urlField: { key: "baseURL", label: "Base URL", placeholder: "https://api.deepseek.com/v1" },
modelsField: {
key: "models",
label: "Models",
placeholder: "deepseek-chat, deepseek-reasoner",
hint: "Comma-separated. These appear in the model dropdown above.",
},
},
{
id: "ollama",
name: "Ollama (local)",
canTranscribe: false,
canAnalyze: true,
transcriptionModels: [],
analysisModels: [],
analysisModelDefault: "llama3.1",
urlField: { key: "baseURL", label: "Ollama Base URL", placeholder: "http://localhost:11434" },
modelsField: {
key: "models",
label: "Models",
placeholder: "llama3.1, mistral, qwen2",
hint: "Comma-separated. Installed models are auto-detected from your Ollama server, so this is only needed if you want a subset.",
},
},
{
// Operator-side relay. Recap doesn't pick a model — relay
// chooses internally based on tier + cost. No credentials UI
// because the install-id + license proof are auto-attached
// server-side; users don't configure anything here.
id: "relay",
name: "Relay (comped credits)",
canTranscribe: true,
canAnalyze: true,
transcriptionModels: ["relay-default"],
analysisModels: ["relay-default"],
// No keyField / urlField / modelsField — the relay's baseURL
// is operator-configured server-side via the "Set Relay URL"
// StartOS action, and identity is auto-managed.
},
];
const PROVIDER_BY_ID = Object.fromEntries(PROVIDERS.map((p) => [p.id, p]));
const TRANSCRIBE_PROVIDERS = PROVIDERS.filter((p) => p.canTranscribe);
const ANALYZE_PROVIDERS = PROVIDERS.filter((p) => p.canAnalyze);
// ── Provider opts persistence ───────────────────────────────────────────
// Shape: { gemini: {apiKey}, anthropic: {apiKey}, openai: {apiKey},
// "openai-compatible": {apiKey, baseURL}, ollama: {baseURL} }.
// Migrates the legacy single-key localStorage entry the first time
// it's seen so users with a saved Gemini key don't lose it.
function loadProviderOpts() {
let opts = {};
try {
const raw = localStorage.getItem("recap-provider-opts");
if (raw) opts = JSON.parse(raw) || {};
} catch {}
const legacyGemini = localStorage.getItem("recap-gemini-key") || "";
if (legacyGemini && !opts.gemini?.apiKey) {
opts.gemini = { ...(opts.gemini || {}), apiKey: legacyGemini };
}
// Ensure every provider has an entry so accesses like
// state.providerOpts.anthropic.apiKey don't error.
for (const p of PROVIDERS) {
if (!opts[p.id]) opts[p.id] = {};
}
return opts;
}
function saveProviderOpts() {
try {
localStorage.setItem("recap-provider-opts", JSON.stringify(state.providerOpts));
} catch {}
// Mirror the gemini key into the legacy storage slot so other code
// paths (and a future rollback) keep working.
const gk = state.providerOpts.gemini?.apiKey || "";
try {
if (gk) localStorage.setItem("recap-gemini-key", gk);
else localStorage.removeItem("recap-gemini-key");
} catch {}
// Keep the live `state.apiKey` synonym (Gemini key) in sync with
// the canonical providerOpts entry. Existing checks against
// state.apiKey continue to work without modification.
state.apiKey = gk;
}
// Two-tier provider model: the user picks either
// - "Recap Relay" → BOTH transcribe + analyze go through the
// operator's relay. The whole pipeline (download, chunked TX,
// chunked AN) runs server-side. One credit, one HTTP kickoff.
// - "Custom Provider/Local" → BOTH steps go to non-relay
// providers. User picks each step independently from Gemini,
// Claude, OpenAI, local-OpenAI-compatible, Ollama, Whisper.
// User pays their own API costs; credits not involved.
//
// This replaces an older model where each step could independently
// pick "relay" or any direct provider. The new constraint is that
// "relay" is all-or-nothing — relay credits aren't priced for
// single-step use and mixing-with-direct led to muddy accounting.
//
// Migration: legacy localStorage entries with BOTH set to "relay"
// land in providerMode="relay"; anything else lands in "custom"
// (and any per-step "relay" picks get bumped to the first non-relay
// option so the saved config remains valid in custom mode).
const NON_RELAY_TRANSCRIBE_PROVIDERS = TRANSCRIBE_PROVIDERS.filter((p) => p.id !== "relay");
const NON_RELAY_ANALYZE_PROVIDERS = ANALYZE_PROVIDERS.filter((p) => p.id !== "relay");
function loadProviderSelection() {
let sel = {};
try {
const raw = localStorage.getItem("recap-providers");
if (raw) sel = JSON.parse(raw) || {};
} catch {}
const savedMode = sel.providerMode || null;
const desiredTrans = sel.transcriptionProvider || "relay";
const desiredAna = sel.analysisProvider || "relay";
// Derive providerMode: if not explicitly saved, infer from the
// saved per-step providers (legacy storage). Fresh installs land
// on "relay" — the comped-credits experience.
const inferredMode =
savedMode || (desiredTrans === "relay" && desiredAna === "relay" ? "relay" : "custom");
if (inferredMode === "relay") {
return {
providerMode: "relay",
transcriptionProvider: "relay",
transcriptionModel: "relay-default",
analysisProvider: "relay",
analysisModel: "relay-default",
};
}
// Custom mode — strip any stale per-step relay picks (relay is
// not available in custom mode) and fall back to the first
// non-relay provider when needed.
const tp =
(desiredTrans !== "relay" &&
NON_RELAY_TRANSCRIBE_PROVIDERS.find((p) => p.id === desiredTrans)) ||
NON_RELAY_TRANSCRIBE_PROVIDERS[0];
const ap =
(desiredAna !== "relay" &&
NON_RELAY_ANALYZE_PROVIDERS.find((p) => p.id === desiredAna)) ||
NON_RELAY_ANALYZE_PROVIDERS[0];
return {
providerMode: "custom",
transcriptionProvider: tp.id,
transcriptionModel:
(desiredTrans === tp.id && sel.transcriptionModel) ||
tp.transcriptionModels[0] ||
"",
analysisProvider: ap.id,
analysisModel:
(desiredAna === ap.id && sel.analysisModel) ||
ap.analysisModels[0] ||
ap.analysisModelDefault ||
"",
};
}
// Master-mode setter — invoked by the top-of-pane radio. Toggles
// between the relay-pipeline mode and the custom-providers mode.
// When switching to relay, force both per-step picks to "relay";
// when switching to custom, bump anything still on "relay" to a
// sane non-relay default so the saved config remains valid.
function setProviderMode(mode) {
if (mode !== "relay" && mode !== "custom") return;
state.providerMode = mode;
if (mode === "relay") {
state.transcriptionProvider = "relay";
state.transcriptionModel = "relay-default";
state.analysisProvider = "relay";
state.analysisModel = "relay-default";
} else {
if (state.transcriptionProvider === "relay") {
const tp = NON_RELAY_TRANSCRIBE_PROVIDERS[0];
state.transcriptionProvider = tp.id;
state.transcriptionModel = tp.transcriptionModels[0] || "";
}
if (state.analysisProvider === "relay") {
const ap = NON_RELAY_ANALYZE_PROVIDERS[0];
state.analysisProvider = ap.id;
state.analysisModel = ap.analysisModels[0] || ap.analysisModelDefault || "";
}
}
saveProviderSelection();
render();
}
// Hard reset back to Recap Relay mode. Surfaced as a small link
// when the user has switched to custom — one click back to the
// out-of-the-box experience without manually re-picking providers.
function resetProvidersToRelay() {
setProviderMode("relay");
}
function saveProviderSelection() {
try {
localStorage.setItem("recap-providers", JSON.stringify({
providerMode: state.providerMode,
transcriptionProvider: state.transcriptionProvider,
transcriptionModel: state.transcriptionModel,
analysisProvider: state.analysisProvider,
analysisModel: state.analysisModel,
}));
} catch {}
}
// ── Activity-log persistence ─────────────────────────────────────────────
// Logs are kept in localStorage so a browser refresh doesn't drop the
// user's record of what just happened (or what's still happening).
// Bounded at MAX_LOG_ENTRIES so a long-running browser session won't
// grow the localStorage payload without limit. Cleared explicitly via
// the "Clear" button in the activity-log drawer.
const LOG_STORAGE_KEY = "recap-activity-log-v1";
const MAX_LOG_ENTRIES = 2000;
function loadLogsFromStorage() {
try {
const raw = localStorage.getItem(LOG_STORAGE_KEY);
if (!raw) return [];
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveLogsToStorage() {
try {
if (state.logs.length > MAX_LOG_ENTRIES) {
state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
}
localStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(state.logs));
} catch {}
}
// Single push site used by every log mutation in the app — keeps the
// localStorage mirror in sync without sprinkling save calls at six
// different push sites in handleSSE / processUrl / etc.
function pushLog(entry) {
state.logs.push(entry);
saveLogsToStorage();
}
function clearLogHistory() {
// Cheap confirm dialog — the action is destructive and there's no
// undo. Users typically clear once per long session, not constantly,
// so the extra click is unintrusive.
if (state.logs.length === 0) return;
if (!confirm(`Clear ${state.logs.length} activity-log entries? This can't be undone.`)) return;
state.logs = [];
state.collapsedLogGroups = new Set();
saveLogsToStorage();
render();
}
// ── State ────────────────────────────────────────────────────────────────
const state = {
url: "",
apiKey: localStorage.getItem("recap-gemini-key") || "",
hasServerKey: false, // will be set by health check
// Persistent per-install UUID minted by the server on first boot.
// Populated from /api/health. Shown in Settings → Install ID for
// verification; will be sent to the upcoming relay backend as
// the owner of comped/paid relay credits.
installId: null,
// Last-known relay credit balance + tier. Populated from
// /api/relay/status on boot and after every relay call.
// { creditsRemaining, tier, lastUpdated, lastError, configured }
// configured=false means the operator hasn't wired up a relay
// base URL yet — the picker still shows "Relay (comped credits)"
// but the option is disabled.
relayStatus: { creditsRemaining: null, tier: null, lastUpdated: null, lastError: null, configured: false },
// Live tier-quota policy from the relay, fetched on boot.
// Drives dynamic copy (e.g. activation screen's credit count
// updates without a Recap release when the operator tunes the
// Core lifetime cap via the relay's Adjust Tier Quotas action).
// Shape: { configured: bool, tiers: {...} | null, core_total_credits, core_gemini_credits, error? }
relayPolicy: { configured: false, tiers: null, core_total_credits: null, core_gemini_credits: null },
// Multi-tenant account state from /api/account/whoami. Drives
// the sign-in / sign-out UI and the operator-vs-tenant settings
// panel split. Default is multi-mode / anonymous / no user so
// the FIRST paint hides admin-only items (Activity Log, full
// Settings) for anonymous visitors who land on a multi-tenant
// Recap. The /api/account/whoami response either confirms that
// (state stays anonymous/trial) or flips to single-mode (which
// bumps isAdmin() back to true and reveals the operator UI).
// Single-mode operators briefly see the lite UI on first paint
// before loadAccount() resolves — typically <200ms, acceptable.
// Shape:
// { loaded, recap_mode: "single"|"multi",
// state: "signed_in"|"trial"|"anonymous",
// user?: { id, email, is_admin, has_license, ... },
// trial?: { credits_remaining, credits_total, credits_used } }
account: { loaded: false, recap_mode: "multi", state: "anonymous", user: null, trial: null },
lanMode: null, // null = unknown, true = home, false = traveling
serverStatus: "connecting", // "connected" | "sleeping" | "disconnected" | "connecting"
model: "gemini-3.1-pro-preview",
showKey: false,
settingsOpen: false,
// "What can I paste here?" popover anchored next to the URL
// input. Click-toggle, click-outside-to-close. State is
// intentionally per-tab (not persisted) — users only need to
// see the format list once or twice before it becomes obvious.
formatsInfoOpen: false,
// Operator panel state for multi-tenant admins — distinct from
// state.admin (which is the single-mode password-gate auth). ops
// is only populated when is_admin === 1 in multi mode. tenants
// is the full per-user table; activity is the recent-signups
// breakdown (IP/UA/hour aggregations). Loaded lazily when the
// operator opens the Settings modal to avoid hitting SQLite on
// every page load.
ops: {
tenants: null, // null = not loaded, [] = loaded but empty
tenantsLoading: false,
tenantsError: null,
activity: null, // { window_hours, signups_by_ip, ... }
activityLoading: false,
activityError: null,
activityHours: 24, // user-selectable window
// Per-row "Grant credits" inline editor state. Keyed by user_id
// so the operator can expand any row and we keep the input
// value while they type. Only one row open at a time keeps
// the UI simple.
grantOpenFor: null, // user_id of the currently-expanded row
grantAmount: "",
grantBusy: false,
// Per-row "Set tier" inline selector (Core / Pro / Max). Same
// one-row-at-a-time model as the credits editor above.
tierOpenFor: null,
tierBusy: false,
// Whether this server holds the relay operator key (set from the
// /api/admin/tenants response). Gates the per-row Tier control —
// false on a self-hosted operator with no matching key.
operatorKeyConfigured: false,
},
// User's own active sessions (lite-settings tenant view). Loaded
// on demand when the Settings modal opens.
mySessions: { rows: null, loading: false, error: null },
// Daily Digest opt-in (lite-settings tenant view). enabled is null
// until loaded from /api/account/digest when the modal opens.
digest: { enabled: null, loading: false, saving: false },
// "Take Recap home" — fetches the tenant's raw license key on
// demand. We don't load this in /api/account/whoami because the
// key is a bearer credential we'd rather not pass through the
// boot-time response. Loaded only when the tenant opens the
// take-home panel (one-click reveal + copy).
takeHome: { licenseKey: null, loading: false, error: null, revealed: false, copied: false },
// Password set/change editor state. Collapsed by default; opens
// when the tenant clicks "Set a password" / "Change password".
passwordOpen: false,
passwordInput: "",
passwordBusy: false,
loading: false,
currentStep: 0,
status: "",
error: null,
videoId: null,
videoTitle: "",
chunks: [],
expandAll: false,
expandedChunks: new Set(),
logs: loadLogsFromStorage(),
logOpen: false,
// Set of separator-entry indices the user has collapsed. Each
// separator (── title ──) anchors one group of log entries that
// follow it until the next separator. Adding state here (rather
// than DOM-only) keeps collapse state stable across re-renders +
// refreshes.
collapsedLogGroups: new Set(),
// history
// Library sidebar default state. On wide screens the library is a
// useful at-a-glance always-on panel. On mobile it covers most of
// the screen — defaulting to closed so first-time visitors see
// the URL input + value prop instead of an empty library panel.
// window.matchMedia is available everywhere we care about.
historyOpen: !window.matchMedia("(max-width: 600px)").matches,
historySessions: {}, // id → session summary
historyMeta: { folders: [], uncategorized: [] },
historyLoaded: false,
collapsedFolders: new Set(),
draggingId: null,
editingFolder: null,
editingSessionTitle: null,
ytdlpVersion: null,
ytdlpLatest: null,
ytdlpUpdateAvailable: false,
ytdlpUpdating: false,
// queue
queue: [], // { id, url, status: 'queued'|'processing'|'done'|'error', error: null }
queueProcessing: false,
queueCollapsed: false,
// subscriptions
subscriptions: [],
subsLoaded: false,
addingSubUrl: "",
addingSubSince: "", // optional cutoff date (YYYY-MM-DD)
addingSubAuto: false, // auto-process new videos (skip queue approval)
addingSubLoading: false,
subCheckLog: [], // persisted log from last subscription check
currentType: "youtube", // "youtube" or "podcast"
videoMinimized: false,
currentSessionId: null,
// Audio-first ("walking mode") TTS, Phase 3. Populated from
// /api/tts/availability. ttsAvailable = relay offers TTS;
// ttsAllowed = AND this user may use it (Max in multi mode).
ttsAvailable: undefined,
ttsAllowed: false,
ttsVoice: null,
// clip collection for selective sharing
clipCollection: [], // { sessionId, chunkIndex, entryIndex (optional) }
clipPanelOpen: false,
// mobile menu
mobileMenuOpen: false,
// cookie status
cookieMethod: "none",
cookieFileAgeDays: null,
cookieFileExpiring: false,
// license (Keysat)
license: {
loaded: false,
state: "loading", // 'loading' | 'licensed' | 'unlicensed' | 'invalid'
reason: null,
licenseId: null,
entitlements: [],
expiresAt: null,
isTrial: false,
productSlug: "recap",
keysatBaseUrl: "",
},
licenseActivating: false,
licenseActivationError: null,
licenseActivationKey: "",
// Free tier: once dismissed, the activation screen no longer
// hard-gates the UI. Persisted so returning unlicensed users land
// straight in the app.
// Default to "skipped" on every boot — the app opens straight to
// the main page. Curious users can reach the activation screen via
// the "I have a key" button in the toolbar (which calls
// showActivationScreen()). Used to persist a "user has seen the
// activation screen" flag in localStorage; that's no longer
// meaningful since we never auto-show on boot anymore.
activationSkipped: true,
// In-app license-purchase modal state. Modal renders the live
// tier list from /api/license/policies (proxied to Keysat) using
// Recap's own typography + colors instead of redirecting buyers
// to Keysat's hosted /buy/<slug> page.
buyOpen: false,
buyLoading: false,
buyError: null,
buyPolicies: null, // { policies: [...] } once fetched
// Three-state view machine inside the modal:
// "tiers" — tier-card picker (default)
// "discount" — interim discount-code entry for the chosen tier
// "polling" — waiting for BTCPay invoice to settle
buyView: "tiers",
// Tier the buyer picked (set when transitioning to "discount"):
// { slug, name, basePriceSats, isRecurring, renewalPeriodDays }
buyDraft: null,
// Discount entry state inside the "discount" view:
// { code, applying, error, finalSats, discountAppliedSats }
buyDiscount: null,
// After the buyer commits (with or without discount):
// { invoiceId, checkoutUrl, amountSats, policySlug, policyName,
// discountAppliedSats, basePriceSats }
buyInvoice: null,
// True while we're polling /api/license/poll/<invoiceId> waiting
// for the BTCPay invoice to settle and the license to be issued.
buyPolling: false,
buyPollError: null,
// ── Buy-credits modal (separate from the license modal above) ──
// Identical state machine pattern: "packages" → "polling" with
// loading / error spinners in between. Lives in its own state
// slice so a buyer can purchase credits without disrupting an
// in-flight license purchase (or vice versa).
buyCreditsOpen: false,
buyCreditsView: "packages", // "packages" | "polling"
buyCreditsPackages: null, // [{credits, sats}]
buyCreditsLoading: false,
buyCreditsError: null,
buyCreditsInvoice: null, // { invoiceId, checkoutUrl, sats, credits }
buyCreditsPolling: false,
buyCreditsPollError: null,
// ── Self-serve subscription modal (cloud / multi-mode) ──────────
// A signed-in Core user buys their own prepaid Pro/Max period via
// the relay-owned tier (core-decoupling). Distinct from the
// Keysat license modal (single-mode "take it home" flow) and the
// buy-credits modal. View machine: "tiers" (pick Pro/Max, Pay with
// Bitcoin pill + Pay by card link) → "polling" (BTCPay invoice
// open, polling /api/billing/status) → "success" (tier flipped).
subscribeOpen: false,
subscribeView: "tiers", // "tiers" | "polling" | "success"
subscribeLoading: false,
subscribeError: null,
subscribePlans: null, // { period_days, plans:[{tier,sats}] }
subscribePreselect: null, // "pro" | "max" | null
subscribeInvoice: null, // { invoiceId, checkoutUrl, sats, tier, periodDays }
subscribeBaseline: null, // { tier, expires_at } captured at buy time
subscribePolling: false,
subscribePollError: null,
subscribeCardNote: null, // inline "card coming soon" note
subscribeSettledTier: null, // tier shown on the success view
// ── First-visit welcome modal ───────────────────────────────────
// Shown ONCE to brand-new anon visitors on a multi-tenant
// Recap (no recap_welcome_seen cookie). Explains the product
// before they have to figure it out from the empty URL input.
// Dismissed by clicking "Get started" — sets a 1-year cookie so
// they never see it again on this browser. Single-mode operator
// installs never show this (they're not "new visitors" in the
// cloud sense — they installed the app deliberately).
welcomeOpen: !(
typeof document !== "undefined" &&
document.cookie
.split(";")
.some((c) => c.trim().startsWith("recap_welcome_seen="))
),
// ── Anon "Sign up" 3-tier modal ─────────────────────────────────
// Opens when an anon visitor clicks the Sign up pill. Shows
// Free / Pro / Max cards. Free path = email + magic-link
// (no payment). Pro/Max path = email + BTCPay invoice + poll
// for settle. On settle the server creates the account, attaches
// the license, and sends a "your account is ready" magic-link
// email. Modal state tracks which view we're in: "cards" (pick
// a tier) → "free_email" (Free card → email input) → "free_sent"
// (confirmation) OR "polling" (Pro/Max → waiting on payment) →
// "purchase_sent" (settle complete, email on the way).
tierSignupOpen: false,
tierSignupView: "cards",
tierSignupPolicies: null, // loaded from /api/license/policies
tierSignupLoading: false,
tierSignupError: null,
tierSignupEmail: "",
tierSignupSelectedTier: null, // "free" | <policy slug>
tierSignupBusy: false,
tierSignupInvoice: null, // { invoiceId, checkoutUrl, tierLabel }
tierSignupPollError: null,
// Admin login gate (set via the StartOS "Set Admin Password" action).
// When `enabled`, no API call works without a valid session cookie,
// and the login screen takes priority over the activation screen.
admin: {
loaded: false,
enabled: false,
authed: false,
username: null,
},
adminLoginUsername: "",
adminLoginPassword: "",
adminLoginError: null,
adminLoggingIn: false,
// Server-tracked in-flight job (free-tier only today). Populated
// on boot from /api/process/current and refreshed via polling, so
// the user still sees what's running after a browser refresh.
currentJob: null,
cancellingJob: false,
// Test-connection state per provider id. providerTesting flips
// true while a request is in flight; providerTestResults stores
// the most recent { ok, text|error, latencyMs }.
providerTesting: {},
providerTestResults: {},
// Whether each provider's per-field server config is populated.
// Shape: { [providerId]: { [fieldName]: bool } }. Populated from
// /api/providers/credentials-status on boot + after Save/Delete.
// Drives "✓ Server-configured" hints and whether the Delete
// button is shown when localStorage is empty.
providerServerStatus: {},
// YouTube captions fast-path toggle. When on (default), Recap
// uses YouTube's own captions when available and skips audio
// download + AI transcription entirely. Off forces a full
// transcription pass (better for speaker labels — captions
// don't have them).
useYouTubeCaptions: localStorage.getItem("recap-use-yt-captions") !== "0",
// Per-provider client-side opts (apiKey + baseURL where applicable).
// Sent verbatim in the request body as `providerOpts`. Server
// overlays them on top of values set via the StartOS actions.
providerOpts: loadProviderOpts(),
// Per-pipeline provider + model selection. Persisted so a user's
// mix-and-match (e.g. Gemini transcribe → Claude analyze) sticks
// across sessions.
...loadProviderSelection(),
};
const MODELS = ["gemini-3.1-pro-preview", "gemini-2.5-pro", "gemini-3-flash-preview", "gemini-2.5-flash", "gemini-3.1-flash-lite"];
// Auto-detect server origin so it works from other devices on the network
const API_BASE = window.location.origin;
function formatTime(seconds) {
const s = 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");
}
// ── Close / Clear ─────────────────────────────────────────────────────────
function closeVideo() {
stopPodcastSync();
state.videoId = null;
state.videoTitle = "";
state.url = "";
state.chunks = [];
state.speakers = null;
state.speakerNames = null;
state.error = null;
state.expandedChunks = new Set();
state.expandAll = false;
state.loading = false;
state.currentStep = 0;
state.currentType = "youtube";
state.status = "";
state.videoMinimized = false;
state.currentSessionId = null;
state.streaming = false;
state.streamWindowsDone = 0;
state.streamWindowsTotal = null;
ytCurrentVideoId = null;
render();
}
// ── Process ──────────────────────────────────────────────────────────────
async function handleSubmit() {
// The selected transcription + analysis providers must each have
// a usable config. Relay counts when the relay URL is reachable
// (the operator-controlled hardcoded URL); other providers count
// when a key/URL is set in localStorage or in the StartOS config.
// See providerCanRun() for the per-provider rules.
if (!state.url.trim()) return;
if (!providersCanRun()) {
const tp = PROVIDER_BY_ID[state.transcriptionProvider]?.name || state.transcriptionProvider;
const ap = PROVIDER_BY_ID[state.analysisProvider]?.name || state.analysisProvider;
const missing = [];
if (!providerCanRun(state.transcriptionProvider)) missing.push(tp);
if (!providerCanRun(state.analysisProvider) && state.analysisProvider !== state.transcriptionProvider) missing.push(ap);
const what = missing.join(" + ");
showToast(
`${what} ${missing.length === 1 ? "isn't" : "aren't"} configured. ` +
`Add a key/endpoint in Settings (or use the operator's StartOS actions), ` +
`or pick Relay from the picker for comped credits.`,
"🔑"
);
return;
}
const url = state.url.trim();
// If already processing — free tier blocks (one at a time),
// paid tier queues for batch.
if (state.loading) {
if (!isLicensed()) {
showToast("Free mode handles one video at a time. Wait for the current one to finish.", "⏳");
return;
}
state.url = "";
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
render();
return;
}
state.url = "";
await processUrl(url);
}
function handleLibraryClick() {
// Library is free for everyone — every summary gets saved.
toggleHistory();
}
function handleSubscribeClick() {
// Subscriptions / channel auto-queue is Pro-only. Route non-Pro
// users into the upgrade flow instead of showing a passive
// toast that doesn't actually unblock them: anon visitors get
// the 3-tier signup modal (Pro is one of the cards), free
// signed-in users get the regular buy-license modal.
if (!hasEntitlement("subscriptions")) {
if (isMulti() && !state.account?.user) {
openTierSignupModal();
} else {
openBuyModal();
}
return;
}
addSubscriptionFromInput();
}
function extractVideoId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
function isChannelUrl(url) {
if (!url) return false;
const u = url.trim().toLowerCase();
// Channel pages: /@handle, /c/name, /channel/ID, /user/name
if (/youtube\.com\/@[^/]+/i.test(u)) return true;
if (/youtube\.com\/(c|channel|user)\/[^/]+/i.test(u)) return true;
// Playlist page
if (/youtube\.com\/playlist\?/i.test(u)) return true;
// Explicit /videos or /streams tab
if (/youtube\.com\/.*\/(videos|streams|shorts)/i.test(u)) return true;
return false;
}
function isPodcastUrl(url) {
if (!url) return false;
const u = url.trim().toLowerCase();
if (u.includes("/feed") || u.includes("/rss") || u.includes("feeds.") || u.includes(".xml")) return true;
if (u.includes("anchor.fm") || u.includes("feeds.buzzsprout") || u.includes("feeds.simplecast")) return true;
if (u.includes("feeds.megaphone") || u.includes("feeds.transistor") || u.includes("feeds.libsyn")) return true;
if (u.includes("feeds.podcastmirror") || u.includes("feeds.acast") || u.includes("feeds.fireside")) return true;
if (u.includes("rss.art19") || u.includes("podbean.com/feed")) return true;
// Apple Podcasts SHOW URL — no `?i=<id>` episode param. Format:
// https://podcasts.apple.com/<country>/podcast/<slug>/id<num>
// The episode variant has `?i=<id>` appended. Use URL parsing
// instead of regex — the original regex's negative lookahead
// was positioned wrong and matched episode URLs too.
if (
/^https?:\/\/(?:www\.)?podcasts\.apple\.com\/[^/]+\/podcast\/[^/]+\/id\d+/i.test(
url.trim(),
)
) {
try {
const parsed = new URL(url.trim());
// If there's no `i` query param, it's a SHOW page.
if (!parsed.searchParams.has("i")) return true;
} catch {
// Malformed URL, can't be a show — fall through to false.
}
}
// Spotify SHOW URL (open.spotify.com/show/<id>) — same idea:
// /show/ = the whole podcast, /episode/ = single episode.
if (/^https?:\/\/(?:open|play)\.spotify\.com\/show\//i.test(url.trim())) {
return true;
}
return false;
}
// Apple Podcasts + Spotify "share" URLs — server resolves them to
// direct audio enclosures before processing, but the UI needs to
// know not to extract a YouTube video ID and not to show the
// YouTube embed during processing.
function isShareEpisodeUrl(url) {
if (!url) return false;
const u = url.trim();
// Single-episode share URL: `?i=<id>` on Apple, `/episode/` on
// Spotify, `/episode/` on Fountain.
if (/^https?:\/\/(?:www\.)?podcasts\.apple\.com\/.*[?&]i=/i.test(u)) return true;
if (/^https?:\/\/(?:open|play)\.spotify\.com\/episode\//i.test(u)) return true;
if (/^https?:\/\/(?:www\.)?fountain\.fm\/episode\//i.test(u)) return true;
return false;
}
function isSubscribeUrl(url) {
return isChannelUrl(url) || isPodcastUrl(url);
}
async function processUrl(url, opts = {}) {
// Pre-flight gate — refuse to start the optimistic flow when
// the visitor has 0 credits to spend, whether that's:
// (a) anonymous + server-side cap reached / trials disabled
// (state === "anonymous" && available_trial_credits === 0)
// (b) trial cookie present but credits_remaining === 0
// Both cases lead to the same modal — sign up (fresh account
// with any unused credits transferred over) or buy credits a
// la carte. The reason field isn't surfaced to the user; we
// pass it through for analytics only.
const acct = state.account;
const isAnonExhausted =
acct && acct.state === "anonymous" &&
(acct.available_trial_credits || 0) === 0;
const isTrialExhausted =
acct && acct.state === "trial" &&
acct.trial &&
(acct.trial.credits_remaining || 0) === 0;
if (isAnonExhausted || isTrialExhausted) {
showTrialExhaustedModal({
reason: isTrialExhausted ? "no_credits" : (acct.trial_blocked_reason || "no_credits"),
});
return;
}
// Apple Podcasts / Spotify share URLs are resolved server-side to
// a direct audio enclosure URL. Mark them as podcast up-front so
// the UI suppresses the YouTube embed and doesn't try to derive
// a video ID.
if (!opts.type && isShareEpisodeUrl(url)) {
opts = { ...opts, type: "podcast" };
}
// Extract video ID immediately so we can show the embed while processing
const earlyVideoId = opts.type !== "podcast" ? extractVideoId(url) : null;
if (earlyVideoId) {
state.videoId = earlyVideoId;
state.videoTitle = "";
ytCurrentVideoId = null;
}
state.currentType = opts.type || "youtube";
state.loading = true;
state.error = null;
state.chunks = [];
state.currentStep = 1;
state.status = "Starting...";
// An explicit submit means the live stream owns the main view.
// Background/batch-queue items (opts.background) DON'T touch this —
// they inherit the user's current intent, so if they've stepped away
// to read a saved episode, the next queued item won't yank them back.
// Opening a saved episode flips this off (see loadSession).
if (!opts.background) state.followStream = true;
// Pre-populate the in-flight banner client-side so the user
// immediately sees what's running + the Cancel button. The server
// ground-truth gets pulled in by the poll loop shortly after.
state.currentJob = {
url,
title: opts.title || "",
startedAt: Date.now(),
elapsedMs: 0,
aborted: false,
};
startCurrentJobPoll();
state.settingsOpen = false;
state.expandedChunks = new Set();
// Push a separator for every video — the activity log persists
// across browser sessions, so users may already have entries from
// prior runs in state.logs even before any push this turn.
const title = opts.title || url;
pushLog({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
render();
try {
const body = {
url,
// Legacy single-key field — kept so older server builds keep
// working. New server resolves it as the gemini fallback.
apiKey: state.apiKey.trim() || "USE_SERVER_KEY",
model: state.model,
// Picker-UI fields: which provider + model handles each
// pipeline step, and per-provider client-side opts (apiKey,
// baseURL) the server overlays on top of its config.
transcriptionProvider: state.transcriptionProvider,
transcriptionModel: state.transcriptionModel,
analysisProvider: state.analysisProvider,
analysisModel: state.analysisModel,
providerOpts: state.providerOpts,
useYouTubeCaptions: state.useYouTubeCaptions,
};
if (opts.type) body.type = opts.type;
if (opts.title) body.title = opts.title;
if (opts.uploadDate) body.uploadDate = opts.uploadDate;
if (opts.episodeId) body.episodeId = opts.episodeId;
const res = await fetch(`${API_BASE}/api/process`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok && res.headers.get("content-type")?.includes("application/json")) {
const err = await res.json();
// If a free-tier job is already in flight, sync the local
// status banner to the server-reported job so the user
// immediately sees what's running + can cancel it.
if (err.error === "processing_in_progress" && err.currentJob) {
state.currentJob = err.currentJob;
render();
}
// Trial-cap rejections — surface the same modal the pre-
// flight gate uses so the user gets a clear Sign up / Buy
// credits choice instead of a generic "Try again". Throw a
// tagged error so the catch block can clear the optimistic
// player + status before showing the modal.
if (err.error === "trial_unavailable" || err.error === "trial_exhausted") {
const e = new Error(err.message || err.error);
e.code = err.error;
throw e;
}
// Prefer the server's human-readable `message` over the
// short error code so users see "A summary is already being
// processed (…)" rather than "processing_in_progress".
throw new Error(err.message || err.error || `Server error: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// Persist eventType across reader chunks. SSE events are
// `event: X\ndata: Y\n\n`, but a single TCP/fetch chunk can split
// between those two lines — when that happens, we'd lose the event
// type and silently drop the event (e.g. the final `result` for a
// long video, where the payload is tens of KB). Reset only after
// dispatch, per the SSE spec.
let eventType = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7);
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
handleSSE(eventType, data);
eventType = "";
}
}
}
} catch (err) {
// Trial-cap server rejection (race past the pre-flight gate
// — e.g., another tab spent the last credit between whoami
// and submit). Clear the optimistic player + status, refresh
// account state so the toolbar pill updates to "Trial used
// up", and show the same modal the pre-flight gate uses.
if (err && (err.code === "trial_unavailable" || err.code === "trial_exhausted")) {
state.videoId = null;
state.videoTitle = "";
state.currentJob = null;
state.currentStep = 0;
state.status = "";
state.error = null;
// Refresh whoami so the toolbar pill flips to the cap-hit
// state (or "0 credits remaining" for the exhausted case)
// immediately, before we render() and show the modal.
try { await loadAccount(); } catch {}
render();
showTrialExhaustedModal({
reason: err.code === "trial_unavailable" ? "ip_cap_reached" : "no_credits",
});
state.loading = false;
state.streaming = false;
return;
}
// Distinguish "the stream dropped while the server was still
// working" from "the server rejected the request." iOS Safari
// is aggressive about killing fetches when you background the
// tab or your cellular signal blips — the EventSource dies
// with a generic "Load failed" / "network connection lost"
// error, but the server keeps processing in the background.
// Without this check the user sees a red error banner even
// though their summary will land in their library in 60s.
//
// Connection-drop signals we recognize across iOS / Android /
// desktop browsers — match-by-substring rather than exact
// because each browser uses a slightly different message.
const dropSignals = [
"Load failed",
"network connection was lost",
"Failed to fetch",
"NetworkError",
"ERR_INTERNET_DISCONNECTED",
"The Internet connection appears to be offline",
];
const raw = err?.message || String(err);
const isDrop = dropSignals.some((s) =>
raw.toLowerCase().includes(s.toLowerCase()),
);
if (isDrop) {
// Confirm there's still a job running on the server before
// we show the friendly message. If the server has nothing
// in flight either, the error is real (probably a network
// failure before the request reached the server).
try {
await loadCurrentJob();
if (state.currentJob) {
state.error = null;
// Re-attach: the current-job poller will pick up the
// result when it lands and refresh history. We start the
// poll here in case it isn't already running.
startCurrentJobPoll();
} else {
state.error = "Connection dropped. Try again.";
}
} catch {
state.error = "Connection dropped. Try again.";
}
} else {
state.error = raw;
}
} finally {
state.loading = false;
state.currentStep = 0;
// Streaming flag must be cleared even on abort/error so the
// "still analyzing…" indicator disappears with the spinner.
state.streaming = false;
// The server-tracked current-job slot is released by the server
// in its own finally; refresh our snapshot so the banner
// disappears immediately rather than waiting for the poll.
loadCurrentJob().finally(() => {
// Following the live job → normal re-render. Reading a saved
// episode → don't rebuild the view (preserves scroll); just
// surgically drop the now-finished in-flight banner.
if (state.followStream) render();
else document.getElementById("inflight-banner")?.remove();
});
// Process next item in queue
processQueue();
}
}
async function processQueue() {
if (state.loading) return;
const next = state.queue.find(q => q.status === "queued");
if (!next) return;
next.status = "processing";
if (state.followStream) render();
await processUrl(next.url, {
type: next.type || "youtube",
title: next.title || "",
uploadDate: next.uploadDate || "",
episodeId: next.videoId || "",
background: true, // batch continuation — don't seize the view
});
// Mark as done (or error) after processUrl finishes
next.status = state.error ? "error" : "done";
next.error = state.error || null;
// Remove completed items from queue
state.queue = state.queue.filter(q => q.status === "queued");
if (state.followStream) render();
}
function removeFromQueue(id) {
state.queue = state.queue.filter(q => q.id !== id);
render();
}
async function approveQueueItem(id) {
const item = state.queue.find(q => q.id === id);
if (!item) return;
item.status = "queued";
// Remove from server auto-queue now that it's approved
fetch(`${API_BASE}/api/auto-queue/${id}`, { method: "DELETE" }).catch(() => {});
render();
// Start processing if not already running
if (!state.loading) processQueue();
}
function approveAllQueue() {
for (const item of state.queue) {
if (item.status === "pending_approval") {
item.status = "queued";
fetch(`${API_BASE}/api/auto-queue/${item.id}`, { method: "DELETE" }).catch(() => {});
}
}
render();
if (!state.loading) processQueue();
}
async function rejectQueueItem(id) {
const item = state.queue.find(q => q.id === id);
if (!item) return;
// Add to skip list on server
fetch(`${API_BASE}/api/auto-queue/${id}/skip`, { method: "POST" }).catch(() => {});
state.queue = state.queue.filter(q => q.id !== id);
render();
}
function handleSSE(event, data) {
if (event === "status") {
state.currentStep = data.step;
state.status = data.message;
// Surgical update — don't full-render, just update status text and step dots
const statusMsg = document.querySelector(".status-msg");
if (statusMsg) statusMsg.textContent = data.message;
// Update step dots (the 3 small circles)
document.querySelectorAll("[title='Download'], [title='Transcribe'], [title='Analyze']").forEach(dot => {
const stepNum = dot.title === "Download" ? 1 : dot.title === "Transcribe" ? 2 : 3;
dot.style.background = state.currentStep > stepNum ? "#4ade80" : state.currentStep === stepNum ? "#60a5fa" : "#1e293b";
});
// Update the pipeline steps (non-split loading view)
document.querySelectorAll(".step").forEach(step => {
const icon = step.querySelector(".step-icon");
if (!icon) return;
const stepNum = step.textContent.includes("Download") ? 1 : step.textContent.includes("Transcribe") ? 2 : 3;
step.classList.toggle("active", state.currentStep === stepNum);
step.classList.toggle("done", state.currentStep > stepNum);
if (state.currentStep > stepNum) icon.textContent = "\u2713";
});
// Update the status text in non-split loading view
const statusText = document.querySelector(".status-text");
if (statusText) statusText.textContent = data.message;
return;
} else if (event === "log") {
pushLog({ elapsed: data.elapsed, message: data.message, detail: data.detail });
renderLog();
} else if (event === "transcript_ready") {
// Server has finished STT and is about to start analyze (in
// pipelined mode this may arrive AFTER some sections_partial
// events — see relay v0.2.89+). Switch the UI from the
// loading screen to a results view; preserve any chunks
// already populated by sections_partial so we don't lose
// pipelined partial sections to a chunks=[] reset. Fresh
// submissions reset state.chunks separately via
// resetAndStartFresh, so we don't need to wipe here.
// Only seize the view if the user is following this live stream.
// If they've navigated to a saved episode, let it keep processing
// in the background without yanking the page over.
if (state.followStream) {
const videoChanged = state.videoId !== data.videoId;
state.videoId = data.videoId;
state.videoTitle = data.videoTitle || "";
state.currentType = data.type || "youtube";
// Only wipe chunks if this is a new video (resetAndStartFresh
// missed it, e.g. retry of a different URL). For same-video
// pipelined flow, keep the partials we've accumulated.
if (videoChanged) state.chunks = [];
state.streaming = true;
if (!state.streamWindowsDone) state.streamWindowsDone = 0;
state.streamWindowsTotal = data.willChunk ? (state.streamWindowsTotal || null) : 1;
state.videoMinimized = false;
state.expandedChunks = new Set();
state.expandAll = false;
if (videoChanged) ytCurrentVideoId = null;
render();
}
} else if (event === "sections_partial") {
// Merge an analyze window's body-owned sections into the
// running chunks list at their correct time positions. The
// server only sends sections that the stitcher will keep,
// so we never have to revise these once rendered.
// Skip when the user has navigated to a saved episode — mixing this
// stream's sections into their current view (or re-rendering it)
// would clobber what they're reading. The window still lands in the
// library when the job completes.
if (state.followStream) {
const incoming = data.chunks || [];
if (incoming.length > 0) {
state.chunks = [...state.chunks, ...incoming].sort(
(a, b) => (a.startTime || 0) - (b.startTime || 0)
);
}
state.streamWindowsDone = (state.streamWindowsDone || 0) + 1;
state.streamWindowsTotal = data.totalWindows || state.streamWindowsTotal;
// Surgical update: just regenerate the chunks-scroll innerHTML
// and the streaming indicator's window-count text. Do NOT call
// render() — a full re-render destroys the YouTube iframe div,
// forcing the YT IFrame API to reload, which produces a
// visible flicker every time a window completes. With this
// pathway the iframe (and any in-progress video playback)
// survives across per-window section deliveries.
applyStreamingChunksUpdate();
}
} else if (event === "result") {
state.streaming = false;
if (!state.followStream) {
// User is reading a saved episode — don't yank the view to the
// finished job. Refresh the library + balance silently (no
// render, so their scroll position is preserved) and just tell
// them it's ready.
loadHistory().catch(() => {});
loadRelayStatus().catch(() => {});
showToast(
`"${(data.videoTitle || "Your recap").slice(0, 40)}" is ready in your library`,
"✓",
5000,
);
return;
}
const videoChanged = state.videoId !== data.videoId;
state.videoId = data.videoId;
state.videoTitle = data.videoTitle || "";
state.chunks = data.chunks || [];
// Phase 1E — speaker legend from the relay's diarization
// pipeline. Map keyed by global speaker ID (Speaker_A, ...)
// with stats per speaker. Null when the operator's relay
// didn't run diarization (off, missing, or version <0.2.88).
// Each entry inside chunks already carries `.speaker` and
// `.speaker_confidence` if diarization ran — set server-side
// by the time-matching pass in server/index.js's relay-mode
// branch.
state.speakers = data.speakers || null;
// Phase 2 — inferred speaker names from the relay's post-
// cluster polish pass. { Speaker_A: "Matt Hill" | null, ... }.
// Legend renderer prefers the inferred name when present;
// chips keep their letter (color identity stable).
state.speakerNames = data.speaker_names || null;
state.currentType = data.type || "youtube";
state.currentSessionId = data.historyId || null;
state.videoMinimized = false;
state.streaming = false;
if (videoChanged) ytCurrentVideoId = null;
render();
// Refresh history list and re-render when loaded
loadHistory().then(() => render());
// Refresh the relay balance — if this job went through the
// relay, the server-side cache was just updated by the
// transcribe/analyze responses, and we want the picker banner
// to reflect the new count.
loadRelayStatus().then(() => render()).catch(() => {});
} else if (event === "error") {
state.streaming = false;
pushLog({ elapsed: "---", message: "ERROR: " + data.message, error: true });
if (state.followStream) {
state.error = data.message;
render();
} else {
// Don't paint a failed-job error over the episode they're reading.
showToast(`Recap failed: ${(data.message || "error").slice(0, 60)}`, "!", 5000);
}
} else if (event === "cancelled") {
// Server confirmed the cancellation went through. The fetch
// reader's finally clause clears state.loading and the banner
// poll catches the slot release a moment later; just log the
// acknowledgement here so the user sees "Cancelled by user"
// in the activity log.
pushLog({ elapsed: "---", message: data.message || "Cancelled by user" });
render();
}
}
// ── Render ───────────────────────────────────────────────────────────────
function renderAdminLoginScreen() {
// Reuses .activation-screen / .activation-card styling so the gate
// looks consistent with the activation screen that follows it.
return `
<div class="activation-screen">
<div class="activation-card">
<h1>Recaps</h1>
<p class="activation-sub">Sign in to continue.</p>
<label class="activation-label">Username</label>
<input class="activation-key" type="text" autocomplete="username"
style="font-family:inherit;"
value="${escHtml(state.adminLoginUsername)}"
oninput="state.adminLoginUsername=this.value; document.getElementById('admin-login-btn').disabled = !canSubmitAdminLogin();"
onkeydown="if(event.key==='Enter'){document.getElementById('admin-login-password').focus();}" />
<label class="activation-label">Password</label>
<input class="activation-key" type="password" id="admin-login-password" autocomplete="current-password"
style="font-family:inherit;"
value="${escHtml(state.adminLoginPassword)}"
oninput="state.adminLoginPassword=this.value; document.getElementById('admin-login-btn').disabled = !canSubmitAdminLogin();"
onkeydown="if(event.key==='Enter' && canSubmitAdminLogin()){submitAdminLogin();}" />
${state.adminLoginError ? `<div class="activation-error">${escHtml(state.adminLoginError)}</div>` : ""}
<div class="activation-actions">
<button id="admin-login-btn" class="activation-btn"
${!canSubmitAdminLogin() ? "disabled" : ""}
onclick="submitAdminLogin()">
${state.adminLoggingIn ? "Signing in…" : "Sign in"}
</button>
</div>
<div class="activation-meta">
The admin password is set on the server via the StartOS
<strong>Set Admin Password</strong> action.
</div>
</div>
</div>
`;
}
function canSubmitAdminLogin() {
return !!(state.adminLoginUsername.trim() && state.adminLoginPassword && !state.adminLoggingIn);
}
async function loadAdminStatus() {
try {
const res = await fetch(`${API_BASE}/api/admin/status`, { credentials: "same-origin" });
const data = await res.json();
state.admin = {
loaded: true,
enabled: !!data.enabled,
authed: !!data.authed,
username: data.username || null,
};
if (state.admin.enabled && state.adminLoginUsername === "" && data.username) {
state.adminLoginUsername = data.username;
}
} catch {
state.admin = {
loaded: true,
enabled: false,
authed: true,
username: null,
};
}
}
async function submitAdminLogin() {
if (!canSubmitAdminLogin()) return;
state.adminLoggingIn = true;
state.adminLoginError = null;
render();
try {
const res = await fetch(`${API_BASE}/api/admin/login`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: state.adminLoginUsername.trim(),
password: state.adminLoginPassword,
}),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data.ok) {
state.adminLoginPassword = "";
state.admin.authed = true;
state.admin.enabled = !!data.enabled;
state.admin.username = data.username || state.admin.username;
// Now that we're authed, kick off the loads that were skipped
// while gated.
await initAfterAdminAuth();
} else {
state.adminLoginError = data.message || "Sign-in failed.";
}
} catch {
state.adminLoginError = "Could not reach the server.";
} finally {
state.adminLoggingIn = false;
render();
}
}
async function submitAdminLogout() {
try {
await fetch(`${API_BASE}/api/admin/logout`, { method: "POST", credentials: "same-origin" });
} catch {}
state.admin.authed = false;
state.adminLoginPassword = "";
state.adminLoginError = null;
render();
}
function renderActivationScreen() {
const lic = state.license;
const reasonHints = {
product_mismatch: "This license is for a different product.",
revoked: "This license has been revoked.",
expired: "This license has expired.",
bad_signature: "This license appears tampered.",
not_found: "This license key was not recognized.",
license_status_unreachable: "Couldn't reach the licensing server. Check that the backend is running.",
};
const reasonHint = lic.reason ? (reasonHints[lic.reason] || lic.reason) : null;
const loading = lic.state === "loading" || !lic.loaded;
const buyUrl = upgradeToProUrl();
return `
<div class="activation-screen">
<div class="activation-card">
<h1>Recaps</h1>
<p class="activation-sub">
${loading
? "Checking license…"
: (() => {
// Pull the credit count live from the relay so this
// line stays accurate when the operator tunes the
// Core lifetime cap without redeploying Recap.
// Falls back to "free" wording if the relay is
// unreachable on first paint.
const credits = state.relayPolicy?.core_total_credits;
const creditsText =
typeof credits === "number" && credits > 0
? `${credits} relay credit${credits === 1 ? "" : "s"}`
: "free relay credits";
return `Activate a Recaps license to unlock channel &amp; podcast subscriptions and auto-queue. Or skip to use free mode — ${creditsText} to process recaps of videos and podcasts on us, plus unlimited recaps when you bring your own AI provider keys or self-hosted model URL.`;
})()
}
</p>
${loading ? "" : `
<label class="activation-label">License key</label>
<textarea class="activation-key" placeholder="LIC1-..." spellcheck="false"
oninput="state.licenseActivationKey=this.value; document.getElementById('activate-btn').disabled = !this.value.trim() || state.licenseActivating">${escHtml(state.licenseActivationKey)}</textarea>
${state.licenseActivationError ? `<div class="activation-error">${escHtml(state.licenseActivationError)}</div>` : ""}
${reasonHint && !state.licenseActivationError ? `<div class="activation-error">${escHtml(reasonHint)}</div>` : ""}
<div class="activation-actions">
<button id="activate-btn" class="activation-btn"
${(!state.licenseActivationKey.trim() || state.licenseActivating) ? "disabled" : ""}
onclick="activateLicense()">
${state.licenseActivating ? "Activating…" : "Activate"}
</button>
<button class="activation-link"
style="background:none;border:none;color:#a5b4fc;cursor:pointer;padding:0;font-size:13px;text-decoration:underline;"
onclick="dismissActivation(); openBuyModal();">Buy a key &rarr;</button>
<button class="activation-link"
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
onclick="dismissActivation()">
Skip &mdash; use free mode
</button>
</div>
<div class="activation-meta">
Product: <strong>${escHtml(lic.productSlug || "recap")}</strong>
${lic.keysatBaseUrl ? ` &middot; Issuer: <strong>${escHtml(lic.keysatBaseUrl.replace(/^https?:\/\//, ""))}</strong>` : ""}
</div>
`}
</div>
</div>
`;
}
function dismissActivation() {
state.activationSkipped = true;
render();
}
// True for anyone who isn't on the top tier — drives whether to show
// the persistent upgrade banner. Covers: unlicensed, unlicensed-skipped,
// Core (full or partial), and any "licensed but missing entitlements"
// anomaly. Pro users see no banner.
function shouldShowUpgradeBanner() {
if (!state.license || !state.license.loaded) return false;
return !isProTier();
}
// ── In-flight job banner ─────────────────────────────────────────────────
// Shown when the server reports an active free-tier job (server-tracked
// so a browser refresh doesn't hide it). Includes a Cancel button that
// hits /api/process/cancel.
function renderCurrentJobBanner() {
if (!state.currentJob) return "";
const job = state.currentJob;
const rawWhat = job.title || job.url || "a video";
// Long YouTube URLs with tracking params (?si=…) blow past the
// viewport on phones and push the Cancel button off-screen. Cap
// the display string; the full URL/title is still in state.
const what = rawWhat.length > 48 ? rawWhat.slice(0, 45) + "…" : rawWhat;
const elapsedStr = formatInflightElapsed(job);
const aborted = job.aborted || state.cancellingJob;
return `
<div id="inflight-banner" style="
margin: 8px 0 12px;
padding: 10px 14px;
background: linear-gradient(90deg, rgba(59,130,246,0.12), rgba(99,102,241,0.10));
border: 1px solid rgba(99,102,241,0.4);
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: #e2e8f0;
font-size: 13px;
">
<span style="flex:1; min-width: 0; overflow: hidden; text-overflow: ellipsis;">
<strong style="color:#93c5fd;">${aborted ? "Cancelling…" : "Processing"}</strong>
<span style="color:#cbd5e1;"> &middot; ${escHtml(what)}</span>
<span style="color:#94a3b8;"> &middot; <span class="inflight-elapsed">${elapsedStr}</span></span>
</span>
<button onclick="cancelCurrentJob()"
${aborted ? "disabled" : ""}
style="flex-shrink:0;background:${aborted ? "#1e293b" : "#dc2626"};color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:${aborted ? "default" : "pointer"};font-size:12px;font-weight:600;${aborted ? "opacity:0.6;" : ""}">
${aborted ? "Cancelling" : "Cancel"}
</button>
</div>
`;
}
// Server-discovered per-provider connection defaults (today: the
// StartOS-hosted Ollama URL + list of installed Ollama models, when
// Recap and Ollama are co-installed). Used as placeholder/default
// in the picker UI when the user hasn't typed a value — fields
// remain editable.
let __providerDiscovery = {};
async function loadProviderDiscovery() {
try {
const res = await fetch(`${API_BASE}/api/providers/discover`, { credentials: "same-origin" });
if (!res.ok) return;
__providerDiscovery = (await res.json()) || {};
} catch {}
}
// Pull the relay's current tier-quota policy so activation copy
// (and any other dynamic credit-count text) reflects whatever the
// operator has the relay configured for, without needing a Recap
// update each time the policy changes. Best-effort: if the relay
// is unreachable, state.relayPolicy keeps its defaults and the
// copy falls back to a hardcoded number.
async function loadRelayPolicy() {
try {
const res = await fetch(`${API_BASE}/api/relay/policy`, {
credentials: "same-origin",
});
if (!res.ok) return;
const data = await res.json();
state.relayPolicy = {
configured: !!data.configured,
tiers: data.tiers || null,
core_total_credits: data.core_total_credits ?? null,
core_gemini_credits: data.core_gemini_credits ?? null,
error: data.error || null,
};
} catch {}
}
// Per-provider, per-field boolean indicating whether the server's
// startos-config.json has a value for that slot. Booleans only —
// never receives the actual key, so screenshots stay safe. Loaded
// on boot and refreshed after every Save / Delete so the picker
// UI's "✓ Server-configured" hints and Delete-button visibility
// stay in sync with what the StartOS actions have written.
async function loadProviderServerStatus() {
try {
const res = await fetch(
`${API_BASE}/api/providers/credentials-status`,
{ credentials: "same-origin" }
);
if (!res.ok) return;
const data = await res.json();
state.providerServerStatus = data.status || {};
} catch {}
}
// Pull the last-known relay credit balance + tier from the server's
// in-process cache. Cheap (no relay round-trip — server returns
// whatever it cached from the most recent /relay/* call). UI polls
// this on boot and after each successful summarize so the balance
// banner stays in sync without an extra relay hit.
// forceRefresh: when true, passes ?refresh=1 so the Recap server
// bypasses its 10-second relayState cache and pings the operator's
// relay /relay/balance right now. Use after a state-changing
// action (license activation, credit purchase) where the user
// expects to see updated tier + credits immediately rather than
// waiting for the next 60-second poll tick.
async function loadRelayStatus(forceRefresh = false) {
try {
const url = `${API_BASE}/api/relay/status` + (forceRefresh ? "?refresh=1" : "");
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) return;
const data = await res.json();
state.relayStatus = {
creditsRemaining: data.creditsRemaining ?? null,
tier: data.tier || null,
lastUpdated: data.lastUpdated || null,
lastError: data.lastError || null,
configured: !!data.configured,
};
} catch {}
}
function discoveredUrlFor(providerId) {
const entry = __providerDiscovery[providerId];
return (entry && entry.baseURL) || "";
}
function discoveredModelsFor(providerId) {
const entry = __providerDiscovery[providerId];
return (entry && Array.isArray(entry.models)) ? entry.models : [];
}
// Parse the user-supplied "Models" credentials field (a free-text
// string the user types in Settings) into a deduplicated array of
// trimmed model names. Accepts comma- and newline-separated input.
function parseUserModels(raw) {
if (!raw || typeof raw !== "string") return [];
const seen = new Set();
return raw
.split(/[,\n]/)
.map((s) => s.trim())
.filter((s) => {
if (!s) return false;
if (seen.has(s)) return false;
seen.add(s);
return true;
});
}
// Resolved list of analysis models for a provider, in priority
// order: user-defined (from credentials) → server-discovered →
// catalog default. Used by the picker dropdown.
function resolvedAnalysisModelsFor(provider) {
if (provider.analysisModels && provider.analysisModels.length > 0) {
return provider.analysisModels;
}
const userList = parseUserModels(state.providerOpts[provider.id]?.models);
if (userList.length > 0) return userList;
const discovered = discoveredModelsFor(provider.id);
if (discovered.length > 0) return discovered;
return [];
}
// Same idea but for transcription. Whisper-compatible / future
// transcription providers with dynamic catalogs (no fixed model
// list) use the user's Models field as their picker source.
function resolvedTranscriptionModelsFor(provider) {
if (provider.transcriptionModels && provider.transcriptionModels.length > 0) {
return provider.transcriptionModels;
}
const userList = parseUserModels(state.providerOpts[provider.id]?.models);
if (userList.length > 0) return userList;
const discovered = discoveredModelsFor(provider.id);
if (discovered.length > 0) return discovered;
return [];
}
function formatInflightElapsed(job) {
if (!job) return "";
const startedAt = job.startedAt || (Date.now() - (job.elapsedMs || 0));
const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
return elapsedSec >= 60
? `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`
: `${elapsedSec}s`;
}
// Surgical update of just the elapsed-time text inside the
// in-flight banner. Lets the poll loop keep the counter ticking
// without re-rendering the whole app (which would wipe the
// activity-log sidebar, YouTube embed iframe, etc.).
function updateInflightElapsedDOM() {
const el = document.querySelector(".inflight-elapsed");
if (el && state.currentJob) {
el.textContent = formatInflightElapsed(state.currentJob);
}
}
async function loadCurrentJob({ withLogs = false } = {}) {
try {
const url = withLogs
? `${API_BASE}/api/process/current?logs=1`
: `${API_BASE}/api/process/current`;
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) return;
const data = await res.json();
state.currentJob = data.job || null;
// Rehydrate activity log from server buffer (boot path only).
// Logs in localStorage already cover entries the client saw
// before the refresh — merge in only the server-side entries
// that aren't already present so the user picks up everything
// emitted after their SSE stream dropped. Dedup key is
// elapsed+message which is unique within a single job.
if (withLogs && state.currentJob && Array.isArray(data.job.logs)) {
const title = state.currentJob.title || state.currentJob.url || "Processing…";
const sepMsg = `── ${title} ──`;
const hasSeparator = state.logs.some(
(l) => l.separator && l.message === sepMsg
);
if (!hasSeparator) {
state.logs.push({ elapsed: "—", message: sepMsg, detail: null, separator: true });
}
const seen = new Set(state.logs.map((l) => `${l.elapsed}|${l.message}`));
let added = 0;
for (const e of data.job.logs) {
const key = `${e.elapsed}|${e.message}`;
if (!seen.has(key)) {
state.logs.push(e);
seen.add(key);
added++;
}
}
if (!hasSeparator || added > 0) saveLogsToStorage();
}
} catch {}
}
async function cancelCurrentJob() {
if (!state.currentJob || state.cancellingJob) return;
state.cancellingJob = true;
render();
try {
await fetch(`${API_BASE}/api/process/cancel`, { method: "POST", credentials: "same-origin" });
} catch {}
// The pipeline polls for the abort flag and bails at the next
// checkpoint — give it ~2s, then refresh the banner state.
setTimeout(() => {
loadCurrentJob().finally(() => {
state.cancellingJob = false;
render();
});
}, 2000);
}
// Poll loop: while a job is in flight, ping the server every 5s to
// keep the banner accurate (elapsed time + did-it-finish detection).
// Stops itself when the job disappears.
let __currentJobPollTimer = null;
let __inflightTickTimer = null;
// Background relay-status refresh. Catches out-of-band balance
// changes that don't go through any Recap-initiated flow —
// operator-side webhook delivery, rescans from the relay
// dashboard, manual ledger edits. 60s interval is short enough
// for the pill to reflect a Lightning purchase within a minute
// of payment landing, low enough to be free at scale.
// Pauses while the document is hidden (battery + bandwidth on
// mobile) and snaps once on visibility restore so coming back
// to a backgrounded tab shows fresh state.
let __relayStatusPollTimer = null;
function startRelayStatusPoll() {
if (__relayStatusPollTimer) return;
// Only re-render when a USER-VISIBLE field of the relay status
// changes (credits, tier, configured-flag, lastError). Compare
// a filtered fingerprint, NOT the whole status object — the
// server stamps lastUpdated on every response, so the naive
// "compare full object" check would always detect a diff and
// re-render on every poll, defeating the whole point.
const visibleFingerprint = (s) => {
if (!s) return "";
return [s.creditsRemaining, s.tier, s.configured, s.lastError].join("|");
};
const pollAndMaybeRender = async () => {
const prev = visibleFingerprint(state.relayStatus);
await loadRelayStatus().catch(() => {});
if (visibleFingerprint(state.relayStatus) !== prev) render();
};
__relayStatusPollTimer = setInterval(() => {
if (document.hidden) return;
pollAndMaybeRender();
}, 60_000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) pollAndMaybeRender();
});
}
function startCurrentJobPoll() {
if (!__currentJobPollTimer) {
__currentJobPollTimer = setInterval(async () => {
const prevHas = !!state.currentJob;
const prevAborted = !!(state.currentJob && state.currentJob.aborted);
await loadCurrentJob();
const has = !!state.currentJob;
const aborted = !!(state.currentJob && state.currentJob.aborted);
// Only do a full render() on presence/state transitions
// (job appears, disappears, or its aborted flag flips).
// The elapsed counter ticks via updateInflightElapsedDOM()
// — a surgical text-node update that leaves the rest of
// the DOM (activity log, YouTube embed, results panel)
// intact between polls.
const transitioned = prevHas !== has || prevAborted !== aborted;
if (transitioned) {
// Following the live job → re-render normally. Reading a saved
// episode → don't rebuild the view (preserve scroll); just drop
// the banner surgically when the job finishes.
if (state.followStream) render();
else if (!has) document.getElementById("inflight-banner")?.remove();
} else if (has) {
updateInflightElapsedDOM();
}
if (!has) {
clearInterval(__currentJobPollTimer);
__currentJobPollTimer = null;
}
}, 5000);
}
// Local tick: refresh the elapsed text once per second so the
// counter never freezes between server polls. Pure DOM update,
// never triggers render().
if (!__inflightTickTimer) {
__inflightTickTimer = setInterval(() => {
if (!state.currentJob) {
clearInterval(__inflightTickTimer);
__inflightTickTimer = null;
return;
}
updateInflightElapsedDOM();
}, 1000);
}
}
// Returns true when the given provider id has enough configuration
// to actually be usable. Used by submit-disabled logic to gate the
// Summarize button so users don't click it with nothing wired up.
// - Relay: usable when the relay URL is reachable
// (state.relayStatus.configured, which reflects the
// operator-controlled hardcoded URL at build time). Credits
// availability is handled server-side — we don't gate the
// click on it, just surface errors after the call lands.
// - Other providers: usable when at least one of the required
// fields (apiKey / baseURL) has a value in either localStorage
// or the StartOS server config. Auto-detected Ollama (via the
// StartOS dependency) counts as configured.
// - Providers with no required fields (currently just relay)
// fall through to the relay branch above.
function providerCanRun(providerId) {
const provider = PROVIDER_BY_ID[providerId];
if (!provider) return false;
if (providerId === "relay") {
// The relay URL is hardcoded into this Recap build, so from
// the client's POV it's always "runnable" the moment the
// user picks it. We DON'T gate on state.relayStatus.configured
// here — that field depends on an async /api/relay/status
// round-trip, which races against the user typing a URL and
// clicking Summarize. If the relay turns out to be unreachable
// at submit time (StartTunnel down, etc.) the SSE response
// surfaces the error inline — much clearer than a silently
// disabled button.
return true;
}
const opts = state.providerOpts[providerId] || {};
const serverFields = state.providerServerStatus?.[providerId] || {};
if (provider.keyField) {
const local = (opts[provider.keyField.key] || "").trim();
if (local) return true;
if (serverFields[provider.keyField.key]) return true;
}
if (provider.urlField) {
const local = (opts[provider.urlField.key] || "").trim();
if (local) return true;
if (serverFields[provider.urlField.key]) return true;
if (providerId === "ollama" && discoveredUrlFor("ollama")) return true;
}
if (!provider.keyField && !provider.urlField) return true;
return false;
}
// Both pipelines (transcription + analysis) must have usable
// configuration for the request to succeed. Per-pipeline check
// because a user can mix providers (e.g. Whisper transcribe +
// Relay analyze).
function providersCanRun() {
return (
providerCanRun(state.transcriptionProvider) &&
providerCanRun(state.analysisProvider)
);
}
// True when the user has at least one AI provider credential set
// (localStorage OR server-side). Used by the toolbar status pill
// to surface "BYO AI keys configured" when relay credits are
// exhausted or unavailable but the user can still summarize via
// their own keys. Excludes the relay provider itself (its identity
// is server-managed, not a BYO credential).
function hasAnyBYOConfigured() {
for (const id of Object.keys(state.providerOpts || {})) {
if (id === "relay") continue;
const opts = state.providerOpts[id] || {};
for (const k of Object.keys(opts)) {
if (typeof opts[k] === "string" && opts[k].trim()) return true;
}
}
for (const id of Object.keys(state.providerServerStatus || {})) {
if (id === "relay") continue;
const fields = state.providerServerStatus[id] || {};
for (const k of Object.keys(fields)) {
if (fields[k]) return true;
}
}
return false;
}
// Compact toolbar status slot — sits between the URL input and
// the right-side icon buttons in the top bar. Shows whichever is
// most useful right now: relay credit balance, "BYO configured",
// or nothing if neither applies. Pairs Upgrade + "I have a key"
// buttons inline so the user has a one-click path to either tier.
function renderToolbarStatus() {
const free = !isLicensed();
const rs = state.relayStatus || {};
const credits = rs.creditsRemaining;
const byo = hasAnyBYOConfigured();
// "Upgrade to Pro" is a per-USER subscription — it only makes
// sense for someone who can OWN a Pro license. In multi-mode an
// anonymous visitor (no account yet) has no users.keysat_license
// row to attach a purchased license to, AND the /api/license/policies
// endpoint sits behind tenant-auth so the buy modal would just
// fail with auth_required (which is what Grant saw — broken UX).
// Hide the button until they sign in.
const showUpgrade = !isProTier() && !(isMulti() && !state.account?.user);
const showIHaveKey = free;
const buyUrl = upgradeToProUrl();
// Tier badge: explicit visual confirmation that the user has
// an active paid license. Prefers Max → Pro → none. Reads from
// entitlements (set on /api/license/activate response) rather
// than the relay's tier field — the license is authoritative
// and the relay's cache may briefly lag during refresh.
let tierBadge = "";
if (hasEntitlement("max")) {
tierBadge = `<span class="tier-badge tier-max" title="Active Max license">MAX</span>`;
} else if (hasEntitlement("pro")) {
tierBadge = `<span class="tier-badge tier-pro" title="Active Pro license">PRO</span>`;
}
// Pick the pill. Relay-credit count beats BYO badge when relay
// is configured — that's the actionable number the user cares
// about. Falls back to BYO badge when relay isn't configured
// (or returned an error) but the user has their own keys.
//
// Special case: anonymous visitors. /api/relay/status returns
// creditsRemaining: null with scope:"anonymous" before they've
// minted a trial cookie. But state.account.available_trial_credits
// (from /api/account/whoami) DOES tell us what they'll get when
// they hit Summarize. Show that as a forward-looking "N free
// credits ready" pill + the Buy more CTA, so the user can see
// what's available AND has a path to buy more before spending
// their trial.
let pillHtml = "";
const anonAvailable =
isMulti() && !state.account?.user
? state.account?.available_trial_credits || 0
: 0;
if (rs.configured && credits != null) {
if (credits < 0) {
pillHtml = `<span class="status-pill have-key-btn" style="cursor:default;">Unlimited Recap credits</span>`;
} else {
// Both pills mirror the .have-key-btn neutral style —
// muted grey on transparent — so the purple "Upgrade"
// remains the only highlighted CTA in the top bar. Whole
// pills are clickable; "+ Buy more" labels the action so
// users know the count is interactive.
//
// As of v0.2.90, anon trial visitors can also buy credits
// (they're applied to their trial cookie, transfer to the
// user account on signup). The "+ Buy more" button is now
// shown to anon, signed-in, and operator users alike.
pillHtml =
`<span class="have-key-btn" style="cursor:default;">${credits} Recap credit${credits === 1 ? "" : "s"}</span>` +
`<button onclick="openBuyCreditsModal()" class="have-key-btn" title="Buy more Recap credits via Lightning">+ Buy more</button>`;
}
} else if (anonAvailable > 0) {
// Anonymous visitor (no trial cookie yet) — surface the
// available trial credits + a Buy more CTA so they don't have
// to summarize anything before they see the pricing path.
pillHtml =
`<span class="have-key-btn" style="cursor:default;color:#a5b4fc;">${anonAvailable} free credit${anonAvailable === 1 ? "" : "s"} ready</span>` +
`<button onclick="openBuyCreditsModal()" class="have-key-btn" title="Buy more Recap credits — they'll attach to your trial and transfer to your account when you sign up">+ Buy more</button>`;
} else if (isMulti() && !state.account?.user && state.account?.trial_blocked_reason === "ip_cap_reached") {
// Anonymous visitor whose server-side IP cap is reached.
// Show a generic "Out of free credits" pill rather than
// anything network-flavored — the underlying mechanism is
// IP-bound but the user-facing message should never hint at
// that. Visitor sees the same copy whether they ran out of
// cookie credits or were refused a fresh cookie mint. Both
// pill buttons open paths that ACTUALLY work (sign up for
// a fresh account; buy credits a la carte without signup).
pillHtml =
`<button onclick="showTrialExhaustedModal({})" class="have-key-btn" title="Out of free credits — sign up or buy credits" style="color:#fcd34d;">Out of free credits</button>` +
`<button onclick="openBuyCreditsModal()" class="have-key-btn" title="Buy Recap credits a la carte">+ Buy credits</button>`;
} else if (byo) {
pillHtml = `<span class="status-pill" title="At least one AI provider key is configured" style="color:#86efac;background:rgba(134,239,172,0.10);border-color:rgba(134,239,172,0.25);">BYO AI keys configured</span>`;
} else if (rs.configured && rs.lastError) {
// Relay configured but unreachable AND no BYO fallback — make
// sure the user sees something so they know summarize will fail.
pillHtml = `<span class="status-pill" title="${escHtml(rs.lastError)}" style="color:#fca5a5;background:rgba(252,165,165,0.10);border-color:rgba(252,165,165,0.30);">Relay unreachable</span>`;
}
// ── Multi-tenant signin widget ──────────────────────────────────
// In multi mode, append a Sign in / Sign out chip so the user
// always knows which account they're on (and how to switch).
// The "I have a key" button is hidden in multi mode — non-admin
// tenants don't activate their own keys via paste; their keys
// attach automatically through the purchase poll-settle handler.
let signinHtml = "";
let hideIHaveKey = false;
if (isMulti()) {
hideIHaveKey = !isAdmin();
const acct = state.account || {};
if (acct.user && acct.user.email) {
// Signed in: short email chip + sign-out icon. Truncate at
// 24 chars so a long email doesn't push the toolbar around.
const shortEmail =
acct.user.email.length > 24
? acct.user.email.slice(0, 22) + "…"
: acct.user.email;
signinHtml = `
<span class="have-key-btn" title="Signed in as ${escHtml(acct.user.email)}"
style="cursor:default;display:inline-flex;align-items:center;gap:6px;">
${escHtml(shortEmail)}
</span>
<a href="/auth/signout" class="have-key-btn"
title="Sign out"
style="text-decoration:none;display:inline-block;">
Sign out
</a>`;
} else {
// Anonymous or active trial: show TWO buttons — primary
// "Sign up" (purple CTA, brings the new visitor to the
// create-account flow) and secondary "Sign in" (muted, for
// returning users). Both go to /auth.html which handles
// both flows from the same magic-link form; we pass
// ?intent=signup or ?intent=signin so the auth page can
// tailor the H1 copy ("Create your account" vs "Sign in
// to Recap"). The user can switch between intents on the
// page if they pick the wrong one.
signinHtml = `
<button onclick="openTierSignupModal()" class="upgrade-btn"
title="Create a Recaps account"
style="display:inline-block;">Sign up</button>
<a href="/auth.html?intent=signin" class="have-key-btn"
title="Sign in to your existing account"
style="text-decoration:none;display:inline-block;">Sign in</a>`;
}
}
if (!pillHtml && !showUpgrade && !showIHaveKey && !tierBadge && !signinHtml) return "";
return `
<div class="top-bar-status">
${tierBadge}
${pillHtml}
${showUpgrade ? `<button onclick="openBuyModal()" class="upgrade-btn">Upgrade</button>` : ""}
${showIHaveKey && !hideIHaveKey ? `<button onclick="showActivationScreen()" class="have-key-btn">I have a key</button>` : ""}
${signinHtml}
</div>
`;
}
function renderUpgradeBanner() {
const buyUrl = upgradeToProUrl();
const free = !isLicensed();
// After the library-for-everyone change, the only meaningful tier
// distinction surfaced in the banner is "Free → upgrade for
// auto-queue + relay credits". Partial-license states no longer
// exist (library + history are universally available).
let label, descr;
if (free) {
label = "Free mode";
descr = "one video at a time &middot; bring your own API key &middot; upgrade for auto-queue, clips, and relay credits";
} else if (!isProTier()) {
label = "Paid license";
descr = "your license is missing some paid features &mdash; contact the seller";
} else {
return ""; // Pro tier (or above) — no banner
}
return `
<div class="upgrade-banner" style="
margin: 8px 0 12px;
padding: 10px 14px;
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
border: 1px solid rgba(168,85,247,0.35);
border-radius: 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
color: #e2e8f0;
font-size: 13px;
">
<span style="flex:1; min-width: 220px;">
<strong style="color:#c4b5fd;">${label}</strong>
&middot; ${descr}
</span>
<button onclick="openBuyModal()"
style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;cursor:pointer;">
Upgrade
</button>
${free ? `
<button onclick="showActivationScreen()"
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
I have a key
</button>
` : ""}
</div>
`;
}
function showActivationScreen() {
// Take the user to the activation modal — triggered by the
// "I have a key" toolbar button. Activation is no longer
// auto-shown on boot; this is the only path that surfaces it.
state.activationSkipped = false;
render();
}
// ── In-app buy modal ────────────────────────────────────────────────────
// Replaces the previous "click Upgrade → window.location to
// keysat.xyz/buy/recap" flow. Modal fetches tiers from the
// /api/license/policies proxy on every open (so operator edits
// in Keysat admin show up without a Recap redeploy), renders
// them in Recap's own visual style, and drives checkout via
// /api/license/purchase. Once the BTCPay invoice settles, the
// server activates the issued license inline and we just refresh
// license-status to pick up the new entitlements.
function formatSats(n) {
if (typeof n !== "number" || !Number.isFinite(n)) return String(n);
return n.toLocaleString("en-US");
}
function cadenceSuffix(days) {
if (days === 7) return " / wk";
if (days === 30) return " / mo";
if (days === 90) return " / qtr";
if (days === 180) return " / 6mo";
if (days === 365) return " / yr";
if (typeof days === "number" && days > 0) return " / " + days + "d";
return "";
}
async function openBuyModal(preselectSlug = null) {
// Core-decoupling: cloud (multi-mode) users buy a RELAY-OWNED
// Pro/Max subscription, not a Keysat license. Route every upgrade
// entry point (toolbar, mobile menu, settings, banners) to the
// self-serve subscription modal. The Keysat license flow below is
// single-mode only — the operator buying a license for their own
// home server. (preselectSlug 'pro'/'max' maps straight to a tier.)
if (isMulti()) {
return openSubscribeModal(preselectSlug);
}
state.buyOpen = true;
state.buyLoading = true;
state.buyError = null;
state.buyPolicies = null;
state.buyView = "tiers";
state.buyDraft = null;
state.buyDiscount = null;
state.buyInvoice = null;
state.buyPolling = false;
state.buyPollError = null;
render();
await loadBuyPolicies();
// If the caller knows which tier the buyer is interested in
// (e.g., "Upgrade to Pro" entry in the mobile menu vs. the
// generic "Upgrade" button), skip the tier-picker step and
// jump straight to the discount-entry view for that policy.
// The tier cards stay reachable via the modal's "Back to
// tiers" affordance if they change their mind.
if (preselectSlug) {
const policy = (state.buyPolicies?.policies || []).find(
(p) => p.slug === preselectSlug,
);
if (policy) buyEnterDiscountStep(policy.slug);
}
}
function closeBuyModal() {
// Stop any in-flight polling and reset modal state.
if (__buyPollTimer) {
clearTimeout(__buyPollTimer);
__buyPollTimer = null;
}
state.buyOpen = false;
state.buyLoading = false;
state.buyError = null;
state.buyView = "tiers";
state.buyDraft = null;
state.buyDiscount = null;
state.buyInvoice = null;
state.buyPolling = false;
state.buyPollError = null;
// Keep state.buyPolicies cached in-memory in case the user
// re-opens the modal — saves a fetch round-trip. It'll be
// refreshed next open.
render();
}
// Click handler for a tier card's "Select" button. Captures the
// tier the buyer picked and transitions the modal into the
// discount-entry view. No invoice is created yet — that happens
// when they click Apply (with a code) or Continue (without).
function buyEnterDiscountStep(policySlug) {
const policy = (state.buyPolicies?.policies || []).find(
(p) => p.slug === policySlug
);
if (!policy) return;
state.buyView = "discount";
state.buyDraft = {
slug: policy.slug,
name: policy.name || policy.slug,
basePriceSats: policy.price_sats || 0,
isRecurring: !!policy.is_recurring,
renewalPeriodDays: policy.renewal_period_days || 0,
featuredDiscountCode: policy.featured_discount?.code || null,
};
state.buyDiscount = {
// Pre-fill with the featured-discount code (launch special)
// if one is active — the buyer can leave it or swap it.
code: policy.featured_discount?.code || "",
applying: false,
error: null,
finalSats: null,
discountAppliedSats: null,
};
state.buyError = null;
render();
}
function buyBackToTiers() {
state.buyView = "tiers";
state.buyDraft = null;
state.buyDiscount = null;
render();
}
// Helper: kick off a purchase with the chosen tier + optional
// code. Shared between "Apply discount" (which we want to PREVIEW
// before opening checkout) and "Continue without code" (which we
// commit to immediately). Returns the parsed envelope or throws.
async function buyCreateInvoice(policySlug, code) {
// Auto-fill buyerEmail from the signed-in cloud user. This is
// forwarded to Keysat as `buyer_email` on /v1/purchase so the
// issued license carries the buyer's email — which Keysat's
// admin UI surfaces for support + manual lookups. Single-mode
// operators don't have a user so this stays undefined and the
// purchase falls back to whatever Keysat's default is (email
// optional in single-mode by policy).
const buyerEmail = isMulti() && state.account?.user?.email
? state.account.user.email
: undefined;
const res = await fetch(`${API_BASE}/api/license/purchase`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
policySlug,
code: code || undefined,
buyerEmail,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.checkout_url) {
const err = new Error(
data.message || data.error || `HTTP ${res.status}`
);
err.status = res.status;
throw err;
}
return data;
}
// "Apply" button inside the discount view. Creates an invoice
// against Keysat with the entered code and shows the discount
// preview — does NOT open the checkout. Buyer can then click
// "Continue to checkout" or revise the code and click Apply
// again (the previous invoice expires on its own).
async function buyApplyDiscount() {
const draft = state.buyDraft;
const disc = state.buyDiscount;
if (!draft || !disc) return;
const code = (disc.code || "").trim();
if (!code) {
state.buyDiscount.error =
"Enter a code first, or click Continue to skip.";
render();
return;
}
state.buyDiscount.applying = true;
state.buyDiscount.error = null;
render();
try {
const data = await buyCreateInvoice(draft.slug, code);
// Keysat /v1/purchase returns the FINAL (post-discount) price
// as `amount_sats` (not `final_price_sats` — the
// developer-facing spec docs were wrong about that field
// name). `base_price_sats` is the pre-discount sticker price
// and `discount_applied_sats` is the absolute savings.
const finalSats = data.amount_sats ?? null;
const discountSats = data.discount_applied_sats ?? 0;
state.buyDiscount.applying = false;
state.buyDiscount.finalSats = finalSats;
state.buyDiscount.discountAppliedSats = discountSats;
// Stash the invoice so "Continue to checkout" can use it
// without creating a second one.
state.buyInvoice = {
invoiceId: data.invoice_id,
checkoutUrl: data.checkout_url,
amountSats: finalSats ?? draft.basePriceSats,
policySlug: draft.slug,
policyName: draft.name,
discountAppliedSats: discountSats,
basePriceSats: data.base_price_sats ?? draft.basePriceSats,
};
render();
} catch (err) {
state.buyDiscount.applying = false;
state.buyDiscount.error =
(err?.message || String(err)).slice(0, 200);
// If the code was rejected, discard any preview state so the
// UI doesn't show stale numbers.
state.buyDiscount.finalSats = null;
state.buyDiscount.discountAppliedSats = null;
state.buyInvoice = null;
render();
}
}
// "Continue without code" button — creates the invoice with no
// code applied, opens checkout, and starts polling. Same as the
// pre-discount-step flow.
async function buyContinueWithoutCode() {
const draft = state.buyDraft;
if (!draft) return;
let checkoutWin = null;
try { checkoutWin = window.open("about:blank", "_blank"); } catch {}
state.buyPolling = true;
state.buyPollError = null;
render();
try {
const data = await buyCreateInvoice(draft.slug, null);
state.buyInvoice = {
invoiceId: data.invoice_id,
checkoutUrl: data.checkout_url,
amountSats: data.amount_sats ?? draft.basePriceSats,
policySlug: draft.slug,
policyName: draft.name,
discountAppliedSats: 0,
basePriceSats: data.base_price_sats ?? draft.basePriceSats,
};
state.buyView = "polling";
if (checkoutWin && !checkoutWin.closed) {
checkoutWin.location.href = data.checkout_url;
} else {
window.location.href = data.checkout_url;
return;
}
startBuyPolling(data.invoice_id);
} catch (err) {
if (checkoutWin) { try { checkoutWin.close(); } catch {} }
state.buyPollError =
(err?.message || String(err)).slice(0, 200);
state.buyPolling = false;
render();
}
}
// After Apply has shown the discount preview, this commits: opens
// the (already-created) BTCPay checkout in a new tab and starts
// polling for the issued license. No new invoice gets created —
// we reuse state.buyInvoice's checkoutUrl + invoiceId.
function buyContinueToCheckout() {
const inv = state.buyInvoice;
if (!inv?.checkoutUrl) return;
let checkoutWin = null;
try { checkoutWin = window.open(inv.checkoutUrl, "_blank"); } catch {}
if (!checkoutWin || checkoutWin.closed) {
// Popup blocked — fall back to same-tab redirect.
window.location.href = inv.checkoutUrl;
return;
}
state.buyView = "polling";
state.buyPolling = true;
state.buyPollError = null;
render();
startBuyPolling(inv.invoiceId);
}
async function loadBuyPolicies() {
state.buyLoading = true;
state.buyError = null;
try {
const res = await fetch(`${API_BASE}/api/license/policies`, {
credentials: "same-origin",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
state.buyError = data.message || data.error || `HTTP ${res.status}`;
} else {
state.buyPolicies = data;
}
} catch (err) {
state.buyError = err?.message || String(err);
} finally {
state.buyLoading = false;
render();
}
}
// (Old buySelectPolicy removed — replaced by the
// buyEnterDiscountStep → buyApplyDiscount / buyContinueWithoutCode
// → buyContinueToCheckout flow above so buyers can preview a
// discount code before BTCPay is opened.)
// Poll the purchase invoice every 4s until it settles or the user
// closes the modal. Settled responses trigger a license-status
// refresh; the server has already written the new license to
// disk by the time we see status:"settled".
let __buyPollTimer = null;
function startBuyPolling(invoiceId) {
if (!invoiceId) return;
state.buyPolling = true;
state.buyPollError = null;
render();
const tick = async () => {
try {
const res = await fetch(
`${API_BASE}/api/license/poll/${encodeURIComponent(invoiceId)}`,
{ credentials: "same-origin" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
state.buyPollError =
data.message || data.error || `HTTP ${res.status}`;
render();
} else if (data.status === "settled" && data.licenseKey) {
// License was activated server-side. Pull the new state
// into the UI + close the modal.
await loadLicenseStatus();
state.buyPolling = false;
state.buyInvoice = null;
closeBuyModal();
showToast("License activated. Welcome aboard.", "✓", 6000);
return;
} else if (data.status === "expired" || data.status === "invalid") {
state.buyPollError =
`Invoice ${data.status}. You can close this and try again from the buy page.`;
state.buyPolling = false;
render();
return;
}
} catch (err) {
state.buyPollError = err?.message || String(err);
render();
}
// Schedule the next poll. 4s feels responsive without
// hammering the licensing server. Stops when the modal is
// closed (closeBuyModal clears the timer).
__buyPollTimer = setTimeout(tick, 4000);
};
// First tick after a short delay so the user has time to load
// the BTCPay checkout page.
__buyPollTimer = setTimeout(tick, 4000);
}
function reopenCheckout() {
const inv = state.buyInvoice;
if (!inv?.checkoutUrl) return;
window.open(inv.checkoutUrl, "_blank");
}
function renderBuyModal() {
if (!state.buyOpen) return "";
let inner;
if (state.buyView === "polling") {
inner = renderBuyPollingView();
} else if (state.buyView === "discount") {
inner = renderBuyDiscountView();
} else if (state.buyLoading) {
inner = renderBuyLoadingView();
} else if (state.buyError) {
inner = renderBuyErrorView();
} else {
inner = renderBuyTierCards();
}
return `
<div class="buy-overlay" onclick="if(event.target===this)closeBuyModal()">
<div class="buy-modal" role="dialog" aria-modal="true">
<div class="buy-header">
<h2>Upgrade Recaps</h2>
<button class="close-btn" onclick="closeBuyModal()" aria-label="Close">&times;</button>
</div>
<div class="buy-body">
${inner}
</div>
</div>
</div>
`;
}
// Interim view shown after the buyer picks a tier but before the
// BTCPay checkout opens. Lets them optionally enter a discount
// code, see the discounted price, and only then commit to opening
// the invoice in BTCPay. Keeps everything in Recap's visual style
// — no Keysat-branded checkout-preview UI involved.
function renderBuyDiscountView() {
const draft = state.buyDraft || {};
const disc = state.buyDiscount || { code: "", applying: false };
const isRecurring = !!draft.isRecurring;
const cadence = isRecurring ? cadenceSuffix(draft.renewalPeriodDays || 30) : "";
const basePrice = draft.basePriceSats || 0;
const hasPreview =
disc.finalSats != null &&
typeof disc.discountAppliedSats === "number";
// Once the discount has been previewed AND applied (an invoice
// exists in state.buyInvoice), the primary CTA flips from
// "Apply" to "Continue to checkout".
const previewBlock = hasPreview
? `
<div class="buy-discount-preview">
<div class="buy-discount-row">
<span>Base price</span>
<span class="buy-discount-old">${formatSats(basePrice)} sats${cadence}</span>
</div>
<div class="buy-discount-row">
<span>You save</span>
<span class="buy-discount-save">${formatSats(disc.discountAppliedSats)} sats</span>
</div>
<div class="buy-discount-row buy-discount-total">
<span>You pay now</span>
<span>${formatSats(disc.finalSats)} sats${cadence}</span>
</div>
</div>
<div class="buy-discount-actions">
<button class="buy-secondary-btn" onclick="buyApplyDiscount()" ${disc.applying ? "disabled" : ""}>Try another code</button>
<button class="buy-select-btn buy-select-btn-primary" onclick="buyContinueToCheckout()">
Continue to checkout &rarr;
</button>
</div>
`
: `
<div class="buy-discount-actions">
<button class="buy-secondary-btn" onclick="buyApplyDiscount()" ${disc.applying ? "disabled" : ""}>
${disc.applying ? "Applying…" : "Apply code"}
</button>
<button class="buy-select-btn buy-select-btn-primary" onclick="buyContinueWithoutCode()" ${disc.applying ? "disabled" : ""}>
Continue without code &rarr;
</button>
</div>
`;
const errBlock = disc.error
? `<div class="buy-poll-error">${escHtml(disc.error)}</div>`
: "";
return `
<div class="buy-discount">
<button class="buy-back-link" onclick="buyBackToTiers()">&larr; Back to tiers</button>
<div class="buy-discount-tier">
<div class="buy-discount-tier-name">${escHtml(draft.name || "")}</div>
<div class="buy-price-row">
<span class="buy-price-new">${formatSats(basePrice)}<span class="buy-price-unit"> sats${cadence}</span></span>
</div>
</div>
<div class="buy-discount-form">
<label class="buy-discount-label">Discount code (optional)</label>
<input class="buy-discount-input" type="text"
placeholder="Enter a code, e.g. LAUNCH50"
value="${escAttr(disc.code || "")}"
oninput="state.buyDiscount.code = this.value; state.buyDiscount.error = null;"
onkeydown="if(event.key==='Enter'){event.preventDefault();buyApplyDiscount();}"
${disc.applying ? "disabled" : ""}
autocomplete="off" spellcheck="false" />
</div>
${previewBlock}
${errBlock}
<div class="buy-discount-hint">
Don't have a code? Click <strong>Continue without code</strong> to pay the standard price.
</div>
</div>
`;
}
function renderBuyLoadingView() {
return `<div class="buy-loading">Loading tiers…</div>`;
}
function renderBuyErrorView() {
return `
<div class="buy-error">
<strong>Couldn't load tiers.</strong>
<div style="margin-top:6px;font-size:12px;color:#fca5a5;">${escHtml(state.buyError)}</div>
<button class="buy-retry-btn" onclick="loadBuyPolicies()">Try again</button>
</div>
`;
}
function renderBuyTierCards() {
const policies = state.buyPolicies?.policies || [];
if (policies.length === 0) {
return `<div class="buy-loading">No tiers are currently available. Check back later.</div>`;
}
const cards = policies.map(renderBuyTierCard).join("");
return `<div class="buy-tier-grid">${cards}</div>`;
}
function renderBuyTierCard(p) {
const isHighlighted = !!p.highlighted;
const isRecurring = !!p.is_recurring;
const cadence = isRecurring ? cadenceSuffix(p.renewal_period_days || 30) : "";
const featured = p.featured_discount;
const baseSats = typeof p.price_sats === "number" ? p.price_sats : 0;
const discountedSats =
featured && typeof featured.final_price_sats === "number"
? featured.final_price_sats
: null;
const discountPct =
featured && typeof featured.percent_off === "number"
? featured.percent_off
: null;
const priceMain = discountedSats != null ? discountedSats : baseSats;
const trialNote =
typeof p.trial_days === "number" && p.trial_days > 0
? `<div class="buy-trial">${p.trial_days}-day free trial</div>`
: "";
const bullets =
Array.isArray(p.marketing_bullets) && p.marketing_bullets.length > 0
? `<ul class="buy-bullets">${p.marketing_bullets
.map((b) => `<li>${escHtml(b)}</li>`)
.join("")}</ul>`
: "";
const badge = isHighlighted
? `<span class="buy-badge">Most popular</span>`
: "";
const discountBadge =
discountPct != null
? `<span class="buy-discount-badge">${discountPct}% OFF</span>`
: "";
const priceBlock =
discountedSats != null
? `<div class="buy-price-row">
<span class="buy-price-old">${formatSats(baseSats)}</span>
<span class="buy-price-new">${formatSats(discountedSats)}<span class="buy-price-unit"> sats${cadence}</span></span>
</div>`
: `<div class="buy-price-row">
<span class="buy-price-new">${formatSats(priceMain)}<span class="buy-price-unit"> sats${cadence}</span></span>
</div>`;
return `
<div class="buy-tier ${isHighlighted ? "buy-tier-highlighted" : ""}">
<div class="buy-tier-top">
<div class="buy-tier-name">${escHtml(p.name || p.slug || "")}</div>
<div class="buy-tier-badges">${badge}${discountBadge}</div>
</div>
${p.description ? `<div class="buy-tier-desc">${escHtml(p.description)}</div>` : ""}
${priceBlock}
${trialNote}
${bullets}
<button class="buy-select-btn ${isHighlighted ? "buy-select-btn-primary" : ""}"
onclick="buyEnterDiscountStep('${escAttr(p.slug)}')">
Select &rarr;
</button>
</div>
`;
}
function renderBuyPollingView() {
const inv = state.buyInvoice;
const sats = inv?.amountSats != null ? formatSats(inv.amountSats) : "—";
const pollError = state.buyPollError
? `<div class="buy-poll-error">${escHtml(state.buyPollError)}</div>`
: "";
return `
<div class="buy-polling">
<div class="buy-polling-spinner">⏳</div>
<h3>Waiting for payment…</h3>
<p>
Your <strong>${escHtml(inv?.policyName || "")}</strong> invoice for
<strong>${sats} sats</strong> is open in another tab.
Once you pay, this screen will activate your license automatically —
usually within 30 seconds of confirmation.
</p>
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="reopenCheckout()">Reopen checkout</button>
<button class="buy-secondary-btn" onclick="closeBuyModal()">Cancel &amp; close</button>
</div>
${pollError}
</div>
`;
}
// ────────────────────────────────────────────────────────────────────
// Self-serve SUBSCRIPTION modal (cloud / multi-mode) — a signed-in
// Core user buys their own prepaid Pro/Max period. The relay owns the
// tier (core-decoupling); Recaps brokers via /api/billing/*. Primary
// rail is Bitcoin/Lightning (BTCPay invoice → poll → tier flips);
// "Pay by card" (Zaprite) is the secondary rail (Phase 4).
// ────────────────────────────────────────────────────────────────────
let subscribePollTimer = null;
let subscribeAutoCloseTimer = null;
let subscribePollDeadline = 0;
// Static per-tier presentation. Prices come from the relay
// (/api/billing/plans) so they stay sourced from operator config;
// only the human-facing label + feature bullets live here.
// The relay-credit bullet is NOT listed here — it's injected
// dynamically in renderSubscribeTierCard from the plan's live
// credits_per_period (sourced from the operator's Adjust-Tier-Quotas
// config), so the card always shows the real allotment.
const SUBSCRIBE_TIER_INFO = {
pro: {
label: "Pro",
blurb: "Subscriptions, auto-queue, and relay credits.",
bullets: [
"Subscribe to channels &amp; podcasts",
"Auto-queue new episodes for summary",
"Priority over the free queue",
],
highlighted: false,
},
max: {
label: "Max",
blurb: "Everything in Pro, plus audio-first walking mode.",
bullets: [
"Everything in Pro",
"Audio-first &ldquo;walking mode&rdquo; playback",
"Speaker names &amp; diarization",
],
highlighted: true,
},
};
async function openSubscribeModal(preselectTier = null) {
state.subscribeOpen = true;
state.subscribeView = "tiers";
state.subscribeLoading = true;
state.subscribeError = null;
state.subscribeInvoice = null;
state.subscribeBaseline = null;
state.subscribePolling = false;
state.subscribePollError = null;
state.subscribeCardNote = null;
state.subscribeSettledTier = null;
// preselectTier comes from the mobile-menu "Upgrade to Max" entry
// (passes 'max'); the generic Upgrade button passes null. We don't
// skip the picker (both tiers stay visible) but we can highlight
// the requested one.
state.subscribePreselect =
preselectTier === "pro" || preselectTier === "max"
? preselectTier
: null;
render();
await loadSubscribePlans();
}
async function loadSubscribePlans() {
state.subscribeLoading = true;
state.subscribeError = null;
render();
try {
const r = await fetch(`${API_BASE}/api/billing/plans`, {
credentials: "same-origin",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(data.message || data.error || `HTTP ${r.status}`);
}
state.subscribePlans = {
periodDays: data.period_days || 30,
plans: Array.isArray(data.plans) ? data.plans : [],
// Card (Zaprite) rail configured? Hide "Pay by card" if not.
cardAvailable: !!data.card_available,
};
if (!state.subscribePlans.plans.length) {
state.subscribeError = "No subscription plans are available right now.";
}
} catch (err) {
state.subscribeError = err.message || String(err);
} finally {
state.subscribeLoading = false;
render();
}
}
function closeSubscribeModal() {
state.subscribeOpen = false;
state.subscribeView = "tiers";
state.subscribeInvoice = null;
state.subscribeBaseline = null;
state.subscribePolling = false;
state.subscribePollError = null;
state.subscribeCardNote = null;
state.subscribeSettledTier = null;
if (subscribePollTimer) {
clearInterval(subscribePollTimer);
subscribePollTimer = null;
}
if (subscribeAutoCloseTimer) {
clearTimeout(subscribeAutoCloseTimer);
subscribeAutoCloseTimer = null;
}
render();
}
// "Pay with Bitcoin" — the primary rail. Asks Recaps to mint a BTCPay
// invoice for the chosen tier (via the relay), then polls
// /api/billing/status until the relay extends the tier. We snapshot the
// current tier + expiry FIRST so a renewal (same tier, later expiry) is
// detected even though the tier label doesn't change.
// method: "bitcoin" (BTCPay/Lightning, default) | "card" (Zaprite).
// Presentation differs by rail: bitcoin WITH a Lightning invoice renders
// an inline QR on THIS screen (no tab, no redirect); card — or bitcoin
// without a LN invoice — sends the buyer to a hosted checkout tab. Both
// webhooks land at extendUserTier, so the /api/billing/status poll is
// rail-agnostic.
async function subscribeBuy(tier, method = "bitcoin") {
if (tier !== "pro" && tier !== "max") return;
if (method !== "bitcoin" && method !== "card") method = "bitcoin";
// CARD goes to a hosted Zaprite checkout, so open the tab
// SYNCHRONOUSLY inside the click gesture (popup-blocker safe).
// BITCOIN renders an inline Lightning invoice on THIS screen — no
// tab, no redirect — so it opens nothing here.
let checkoutWin = null;
if (method === "card") {
try { checkoutWin = window.open("about:blank", "_blank"); } catch {}
}
state.subscribeLoading = true;
state.subscribeError = null;
state.subscribeCardNote = null;
render();
// Snapshot baseline (best-effort) so renewals are detectable.
let baseline = { tier: "core", expires_at: null };
try {
const sr = await fetch(`${API_BASE}/api/billing/status`, {
credentials: "same-origin",
});
if (sr.ok) {
const sd = await sr.json().catch(() => ({}));
baseline = {
tier: sd.tier || "core",
expires_at: sd.expires_at || null,
};
}
} catch {}
state.subscribeBaseline = baseline;
try {
const r = await fetch(`${API_BASE}/api/billing/buy`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier, method }),
});
const data = await r.json().catch(() => ({}));
// Usable if we got EITHER a hosted checkout URL OR an inline
// Lightning invoice. (BTCPay essentially always returns a
// checkoutLink, but the inline bitcoin path only needs bolt11.)
if (!r.ok || (!data.checkout_url && !data.bolt11)) {
throw new Error(data.message || data.error || `HTTP ${r.status}`);
}
state.subscribeInvoice = {
method: data.method || method,
invoiceId: data.invoice_id || null,
orderId: data.order_id || null,
checkoutUrl: data.checkout_url,
// Lightning BOLT11 for the inline QR (bitcoin rail). Null → fall
// back to opening the hosted checkout.
bolt11: data.bolt11 || null,
lightningPaymentLink: data.lightning_payment_link || null,
lightningExpiresAt: data.lightning_expires_at || null,
// Bitcoin rail carries sats; card rail carries a fiat
// amount (smallest unit) + currency.
sats: data.sats ?? null,
amount: data.amount ?? null,
currency: data.currency || null,
tier: data.tier || tier,
periodDays: data.period_days,
};
state.subscribeView = "polling";
// Bitcoin WITH a Lightning invoice → stay INLINE (render the QR on
// this screen; the poll on /api/billing/status drives the settle).
// Card, or bitcoin without LN → send the buyer to the hosted
// checkout (the ?billing=success redirect + boot sync flip the badge
// on return).
const goInline =
state.subscribeInvoice.method === "bitcoin" &&
!!state.subscribeInvoice.bolt11;
if (!goInline) {
if (checkoutWin && !checkoutWin.closed) {
checkoutWin.location.href = data.checkout_url;
} else {
try { checkoutWin = window.open(data.checkout_url, "_blank"); } catch {}
if (!checkoutWin) {
window.location.href = data.checkout_url;
return;
}
}
} else if (checkoutWin) {
// (bitcoin doesn't pre-open a tab, but be safe.)
try { checkoutWin.close(); } catch {}
}
startSubscribePoll(state.subscribeInvoice.tier);
} catch (err) {
if (checkoutWin) { try { checkoutWin.close(); } catch {} }
state.subscribeError = (err?.message || String(err)).slice(0, 200);
state.subscribeView = "tiers";
} finally {
state.subscribeLoading = false;
render();
}
}
const SUBSCRIBE_TIER_RANK = { core: 0, pro: 1, max: 2 };
function startSubscribePoll(purchasedTier) {
if (subscribePollTimer) clearInterval(subscribePollTimer);
state.subscribePolling = true;
state.subscribePollError = null;
// Stop hammering the relay after ~20 min; the webhook still lands
// server-side, and the ?billing=success redirect / next page load
// will reconcile. The modal just stops the live spinner.
subscribePollDeadline = Date.now() + 20 * 60 * 1000;
subscribePollTimer = setInterval(async () => {
if (Date.now() > subscribePollDeadline) {
clearInterval(subscribePollTimer);
subscribePollTimer = null;
state.subscribePolling = false;
state.subscribePollError =
"Still waiting on payment. You can close this — your plan will " +
"activate automatically once the payment confirms.";
render();
return;
}
try {
const r = await fetch(`${API_BASE}/api/billing/status`, {
credentials: "same-origin",
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
state.subscribePollError =
data.message || data.error || `HTTP ${r.status}`;
render();
return;
}
const base = state.subscribeBaseline || { tier: "core", expires_at: null };
const curTier = data.tier || "core";
const baseRank = SUBSCRIBE_TIER_RANK[base.tier] ?? 0;
const wantRank = SUBSCRIBE_TIER_RANK[purchasedTier] ?? 0;
const baseExp = base.expires_at ? Date.parse(base.expires_at) : 0;
const curExp = data.expires_at ? Date.parse(data.expires_at) : 0;
// Settled when the tier upgraded to what they bought, OR the
// expiry advanced (a same-tier renewal/extension).
const upgraded = curTier === purchasedTier && baseRank < wantRank;
const extended = curExp > baseExp + 1000;
if (upgraded || extended) {
clearInterval(subscribePollTimer);
subscribePollTimer = null;
state.subscribePolling = false;
state.subscribeSettledTier = curTier;
// Refresh every surface that reads tier/credits so the badge,
// pill, and gates all flip when the modal closes.
try {
await Promise.all([
loadLicenseStatus().catch(() => {}),
loadAccount().catch(() => {}),
loadRelayStatus(true).catch(() => {}),
]);
} catch {}
state.subscribeView = "success";
render();
if (subscribeAutoCloseTimer) clearTimeout(subscribeAutoCloseTimer);
subscribeAutoCloseTimer = setTimeout(() => {
subscribeAutoCloseTimer = null;
if (state.subscribeOpen && state.subscribeView === "success") {
closeSubscribeModal();
}
}, 3200);
}
} catch (err) {
state.subscribePollError = err.message || String(err);
render();
}
}, 3000);
}
function reopenSubscribeCheckout() {
const inv = state.subscribeInvoice;
if (!inv?.checkoutUrl) return;
try { window.open(inv.checkoutUrl, "_blank"); } catch {}
}
// "Pay by card" — Zaprite rail. Same machine as Bitcoin, just a
// different `method`. Only shown when the relay reports the card rail
// is configured (state.subscribePlans.cardAvailable).
function subscribePayByCard(tier) {
subscribeBuy(tier, "card");
}
// Format a fiat amount given in the currency's smallest unit (cents
// for USD) as a human price, e.g. 2100 + "USD" → "$21". Drops the
// decimals when the amount is a whole unit.
function formatFiat(smallestUnit, currency) {
const cur = (currency || "USD").toUpperCase();
const n = Number(smallestUnit);
if (!Number.isFinite(n)) return "";
const major = n / 100;
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: cur,
minimumFractionDigits: Number.isInteger(major) ? 0 : 2,
maximumFractionDigits: 2,
}).format(major);
} catch {
// Unknown currency code — fall back to a plain number + code.
return `${major} ${cur}`;
}
}
// Boot-time reconcile after a BTCPay subscription checkout. When the
// buyer follows the "return to merchant" redirect they land on
// /?billing=success — even if the in-modal poller never ran (popup
// blocked → same-tab navigation away from the app). Sync the relay-
// owned tier into the local cache and refresh every tier-reading
// surface so the badge + gates flip. The settle webhook can land a
// beat after the redirect, so retry a few times until it shows.
async function handleBillingReturn() {
let params;
try {
params = new URLSearchParams(window.location.search);
} catch {
return;
}
if (params.get("billing") !== "success") return;
// Strip the marker so a manual refresh doesn't re-trigger this.
try {
params.delete("billing");
const qs = params.toString();
const url =
window.location.pathname +
(qs ? "?" + qs : "") +
window.location.hash;
window.history.replaceState({}, "", url);
} catch {}
if (!isMulti()) return;
for (let attempt = 1; attempt <= 4; attempt++) {
try {
// /api/billing/status syncs the cached users.tier from the
// relay as a side effect; then refresh the tier-reading state.
await fetch(`${API_BASE}/api/billing/status`, {
credentials: "same-origin",
}).catch(() => {});
await Promise.all([
loadLicenseStatus().catch(() => {}),
loadAccount().catch(() => {}),
loadRelayStatus(true).catch(() => {}),
]);
render();
if (isProTier()) {
showToast("Your plan is active. Welcome aboard!", "✓", 6000);
return;
}
} catch {}
if (attempt < 4) {
await new Promise((r) => setTimeout(r, 2500));
}
}
}
// Boot-time handler for the expiry-reminder email's "Renew" link
// (/?renew=1). A signed-in account holder lands straight in the
// purchase modal; otherwise we nudge them to sign in first (their
// session may have lapsed or they opened it on another device).
function handleRenewLink() {
let params;
try {
params = new URLSearchParams(window.location.search);
} catch {
return;
}
if (params.get("renew") !== "1") return;
try {
params.delete("renew");
const qs = params.toString();
const url =
window.location.pathname +
(qs ? "?" + qs : "") +
window.location.hash;
window.history.replaceState({}, "", url);
} catch {}
if (!isMulti()) return;
if (state.account?.user) {
openSubscribeModal();
} else {
showToast("Sign in to renew your plan, then tap Upgrade.", "→", 6000);
}
}
function renderSubscribeModal() {
if (!state.subscribeOpen) return "";
let inner;
if (state.subscribeView === "polling") {
inner = renderSubscribePollingView();
} else if (state.subscribeView === "success") {
inner = renderSubscribeSuccessView();
} else if (state.subscribeLoading && !state.subscribePlans) {
inner = `<div class="buy-loading">Loading plans…</div>`;
} else if (state.subscribeError && !state.subscribePlans) {
inner = `
<div class="buy-error">
<strong>Couldn't load plans.</strong>
<div style="margin-top:6px;font-size:12px;color:#fca5a5;">${escHtml(state.subscribeError)}</div>
<button class="buy-retry-btn" onclick="loadSubscribePlans()">Try again</button>
</div>`;
} else {
inner = renderSubscribeTierCards();
}
// Collapse the wide multi-card modal to a tight single-column card for
// the inline Lightning invoice + success views, and swap the header —
// mirrors the buy-credits inline flow (.buy-modal is 1000px by default,
// sized for the side-by-side tier cards).
const inlinePay =
state.subscribeView === "polling" && !!state.subscribeInvoice?.bolt11;
const compact = inlinePay || state.subscribeView === "success";
const modalStyle = compact ? `style="width:420px;max-width:100%;"` : "";
let headerLabel = "Upgrade your plan";
if (inlinePay) headerLabel = "Pay with Lightning";
else if (state.subscribeView === "success") headerLabel = "Plan activated";
return `
<div class="buy-overlay" onclick="if(event.target===this)closeSubscribeModal()">
<div class="buy-modal" role="dialog" aria-modal="true" ${modalStyle}>
<div class="buy-header">
<h2>${headerLabel}</h2>
<button class="close-btn" onclick="closeSubscribeModal()" aria-label="Close">&times;</button>
</div>
<div class="buy-body">
${inner}
</div>
</div>
</div>
`;
}
function renderSubscribeTierCards() {
const plans = state.subscribePlans?.plans || [];
const periodDays = state.subscribePlans?.periodDays || 30;
if (!plans.length) {
return `<div class="buy-loading">No plans available right now.</div>`;
}
const cadence = cadenceSuffix(periodDays);
const topErr = state.subscribeError
? `<div class="buy-poll-error" style="margin-bottom:12px;">${escHtml(state.subscribeError)}</div>`
: "";
const cardNote = state.subscribeCardNote
? `<div class="sub-card-note">${escHtml(state.subscribeCardNote)}</div>`
: "";
const cards = plans
.map((p) => renderSubscribeTierCard(p, cadence))
.join("");
const busy = state.subscribeLoading ? "Opening checkout…" : "";
return `
${topErr}
<div class="buy-tier-grid">${cards}</div>
${cardNote}
${busy ? `<div class="sub-busy">${busy}</div>` : ""}
<div class="sub-foot-hint">
Prepaid for ${periodDays} days. We'll email you before it expires —
pay again to extend. No auto-charges.
</div>
`;
}
function renderSubscribeTierCard(plan, cadence) {
const info = SUBSCRIBE_TIER_INFO[plan.tier] || {
label: plan.tier,
blurb: "",
bullets: [],
highlighted: false,
};
const highlighted =
info.highlighted || state.subscribePreselect === plan.tier;
// Dynamic relay-credit bullet from the live operator quota config:
// a number → "N relay credits each period"; null → "Unlimited relay
// credits"; absent (relay-unreachable fallback) → generic copy.
const cpp = plan.credits_per_period;
let creditsBullet;
if (typeof cpp === "number") {
creditsBullet = `${cpp.toLocaleString("en-US")} relay credits each period`;
} else if (cpp === null) {
creditsBullet = "Unlimited relay credits";
} else {
creditsBullet = "Relay credits included each period";
}
const allBullets = [creditsBullet, ...info.bullets];
const bullets = allBullets.length
? `<ul class="buy-bullets">${allBullets
.map((b) => `<li>${b}</li>`)
.join("")}</ul>`
: "";
const disabled = state.subscribeLoading ? "disabled" : "";
// Card rail: only offer it when the relay says Zaprite is configured.
// Show the fiat price on the link so the buyer knows the card amount
// (which may carry a small premium over the sat price) before they
// leave for the hosted checkout.
const cardAvailable = !!state.subscribePlans?.cardAvailable;
const cardPrice =
plan.fiat_amount != null
? formatFiat(plan.fiat_amount, plan.fiat_currency)
: "";
const cardBtn = cardAvailable
? `<button class="sub-pay-card" onclick="subscribePayByCard('${escAttr(plan.tier)}')" ${disabled}>
Pay by card${cardPrice ? ` · ${escHtml(cardPrice)}` : ""}
</button>`
: "";
return `
<div class="buy-tier ${highlighted ? "buy-tier-highlighted" : ""}">
<div class="buy-tier-top">
<div class="buy-tier-name">${escHtml(info.label)}</div>
<div class="buy-tier-badges">${highlighted ? `<span class="buy-badge">Most popular</span>` : ""}</div>
</div>
${info.blurb ? `<div class="buy-tier-desc">${info.blurb}</div>` : ""}
<div class="buy-price-row">
<span class="buy-price-new">${formatSats(plan.sats)}<span class="buy-price-unit"> sats${cadence}</span></span>
</div>
${bullets}
<button class="buy-select-btn buy-select-btn-primary sub-pay-btc"
onclick="subscribeBuy('${escAttr(plan.tier)}', 'bitcoin')" ${disabled}>
<span class="sub-btc-glyph">₿</span> Pay with Bitcoin
</button>
${cardBtn}
</div>
`;
}
function renderSubscribePollingView() {
const inv = state.subscribeInvoice || {};
const info = SUBSCRIBE_TIER_INFO[inv.tier] || { label: inv.tier || "" };
const isCard = inv.method === "card";
const amountText = isCard
? inv.amount != null
? formatFiat(inv.amount, inv.currency)
: "—"
: inv.sats != null
? `${formatSats(inv.sats)} sats`
: "—";
const spinner = isCard ? "💳" : "⚡";
const pollError = state.subscribePollError
? `<div class="buy-poll-error">${escHtml(state.subscribePollError)}</div>`
: "";
// INLINE Lightning invoice (bitcoin rail with a BOLT11) — render the
// QR + invoice ON THIS screen, no new tab / no redirect. The poll on
// /api/billing/status flips to the success view when payment lands.
// The QR itself is painted post-render by mountSubscribeQr(), keyed
// off the #subscribe-qr-mount node below. Mirrors the buy-credits
// inline flow exactly.
if (inv.method === "bitcoin" && inv.bolt11) {
const lnUri = `lightning:${inv.bolt11}`;
const satsText = inv.sats != null ? formatSats(inv.sats) : "—";
return `
<div style="text-align:center;max-width:300px;margin:0 auto;">
<h3 style="margin:0 0 2px;font-size:18px;">Pay ${satsText} sats</h3>
<div style="font-size:11px;color:#94a3b8;margin-bottom:14px;">
${escHtml(info.label)} · scan with any Lightning wallet
</div>
<div id="subscribe-qr-mount"
style="background:#fff;padding:10px;border-radius:8px;display:inline-block;margin-bottom:12px;line-height:0;">
<div style="color:#94a3b8;font-size:11px;padding:80px 60px;line-height:1.4;">Generating QR…</div>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:10px;">
<code style="flex:1;min-width:0;font-size:11px;color:#94a3b8;background:#0a0e1a;padding:7px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:ltr;text-align:left;">${escHtml(inv.bolt11)}</code>
<button class="buy-secondary-btn" onclick="copyInvoiceId('${escAttr(inv.bolt11)}', this)"
style="flex-shrink:0;padding:7px 12px;font-size:11px;font-weight:600;">Copy</button>
</div>
<a href="${escAttr(lnUri)}"
style="display:block;padding:10px 18px;font-size:13px;font-weight:600;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;">
⚡ Open in wallet
</a>
<div style="margin-top:10px;font-size:10px;color:#64748b;line-height:1.5;">
Your plan activates automatically when payment lands.
</div>
${pollError}
<button onclick="closeSubscribeModal()"
style="margin-top:10px;background:transparent;color:#64748b;border:none;font-size:11px;text-decoration:underline;cursor:pointer;">
Cancel
</button>
</div>
`;
}
// Fallback (card, or bitcoin without a Lightning invoice) — waiting on
// a hosted checkout opened in another tab.
return `
<div class="buy-polling">
<div class="buy-polling-spinner"${isCard ? ' style="filter:none;"' : ""}>${spinner}</div>
<h3>Waiting for payment…</h3>
<p>
Your <strong>${escHtml(info.label)}</strong> checkout for
<strong>${escHtml(amountText)}</strong> is open in another tab.
Once you pay, your plan activates here automatically —
usually within a few seconds of confirmation.
</p>
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="reopenSubscribeCheckout()">Reopen checkout</button>
<button class="buy-secondary-btn" onclick="closeSubscribeModal()">Cancel &amp; close</button>
</div>
${pollError}
</div>
`;
}
function renderSubscribeSuccessView() {
const tier = state.subscribeSettledTier || "pro";
const info = SUBSCRIBE_TIER_INFO[tier] || { label: tier };
return `
<div class="buy-polling">
<div class="buy-polling-spinner" style="filter:none;">✅</div>
<h3>You're on ${escHtml(info.label)}!</h3>
<p>Your plan is active. Enjoy ${escHtml(info.label)} — this window will close on its own.</p>
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="closeSubscribeModal()">Done</button>
</div>
</div>
`;
}
// ────────────────────────────────────────────────────────────────────
// Buy CREDITS modal — relay credit top-ups via the operator's BTCPay
// store. Same visual language as the license-purchase modal above
// (reuses .buy-overlay / .buy-modal / .buy-tier / .buy-select-btn
// classes) so the buyer experience stays consistent.
// ────────────────────────────────────────────────────────────────────
let buyCreditsPollTimer = null;
let buyCreditsAutoCloseTimer = null;
// ── Trial-exhausted modal ────────────────────────────────────────
// Shown when an anonymous visitor tries to summarize but their IP
// is at the operator's trials_per_ip_lifetime cap (or trials are
// configured off). Two CTAs: Sign up (fresh account, IP-
// independent, transfers any trial credits the visitor already
// has) and Buy credits (a la carte; no signup required for the
// anon path, attaches to the visitor's trial cookie even when
// the cookie can't be MINTED — credits-purchase.js force-mints
// a cookie with credits_total=0 for the purchase, then the
// bought credits go on top). Closes on outside click + Escape.
function showTrialExhaustedModal({ reason } = {}) {
// Idempotent — if it's already open, focus it.
const existing = document.getElementById("trial-exhausted-modal");
if (existing) return;
// Same headline + body for every reason. Earlier versions had
// a distinct "Free trial used up — from this device or network"
// copy for the IP-cap path, but that phrasing tells the
// determined visitor "swap your IP and you're back in"
// (the cap really IS IP-bound, but we don't need to broadcast
// the implementation). Generic "out of free credits" is
// honest, doesn't telegraph the bypass, and works whether the
// visitor used their cookie credits OR can't mint a new
// cookie — both cases are the same outcome for them.
const headline = "Out of free credits";
const body =
"Your free credits are used up. Sign up to keep going (any unused credits transfer to your account), or buy credits a la carte from this browser.";
const overlay = document.createElement("div");
overlay.id = "trial-exhausted-modal";
overlay.className = "settings-overlay";
overlay.style.zIndex = "2200";
overlay.innerHTML =
'<div class="settings-modal" style="max-width:460px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>' + headline + '</h2>' +
'<button class="close-btn" id="trial-exhausted-close">&times;</button>' +
'</div>' +
'<div class="settings-modal-body" style="font-size:14px; line-height:1.6; color:#cbd5e1;">' +
'<p style="margin-top:0;">' + body + '</p>' +
'<div style="display:flex; gap:10px; flex-wrap:wrap; margin-top:18px;">' +
'<button class="submit-btn" id="trial-exhausted-signup" style="flex:1; min-width:140px;">Sign up free</button>' +
'<button class="expand-btn" id="trial-exhausted-buy" style="flex:1; min-width:140px;">Buy credits</button>' +
'</div>' +
'<div style="margin-top:14px; font-size:12px; color:#94a3b8; line-height:1.5;">' +
'Already have an account? <a href="#" id="trial-exhausted-signin" style="color:#a5b4fc;">Sign in</a>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const close = () => overlay.remove();
document.getElementById("trial-exhausted-close").onclick = close;
overlay.onclick = close;
document.getElementById("trial-exhausted-signup").onclick = () => { close(); openTierSignupModal(); };
document.getElementById("trial-exhausted-buy").onclick = () => { close(); openBuyCreditsModal(); };
document.getElementById("trial-exhausted-signin").onclick = (ev) => {
ev.preventDefault();
close();
if (typeof openSigninModal === "function") openSigninModal();
else openTierSignupModal();
};
const onKey = (ev) => {
if (ev.key === "Escape") { ev.preventDefault(); close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
}
window.showTrialExhaustedModal = showTrialExhaustedModal;
async function openBuyCreditsModal() {
state.buyCreditsOpen = true;
state.buyCreditsView = "packages";
state.buyCreditsLoading = true;
state.buyCreditsError = null;
state.buyCreditsPackages = null;
state.buyCreditsInvoice = null;
state.buyCreditsPolling = false;
state.buyCreditsPollError = null;
render();
// Up to 2 attempts. Safari iOS sometimes silently aborts the
// very first fetch from a cold tab and surfaces it as a
// "Load failed" TypeError — second try works because the
// TCP/TLS state is warm. Short backoff between attempts so a
// genuinely-down endpoint surfaces quickly.
let lastErr = null;
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const r = await fetch("/api/credits/packages");
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.message || err.error || `HTTP ${r.status}`);
}
const data = await r.json();
state.buyCreditsPackages = Array.isArray(data?.packages) ? data.packages : [];
if (state.buyCreditsPackages.length === 0) {
state.buyCreditsError = "No credit bundles are currently offered.";
}
lastErr = null;
break;
} catch (err) {
lastErr = err;
if (attempt < 2) {
await new Promise((r) => setTimeout(r, 600));
}
}
}
if (lastErr && !state.buyCreditsPackages) {
state.buyCreditsError = lastErr.message || String(lastErr);
}
state.buyCreditsLoading = false;
render();
}
function closeBuyCreditsModal() {
state.buyCreditsOpen = false;
state.buyCreditsView = "packages";
state.buyCreditsInvoice = null;
state.buyCreditsPolling = false;
state.buyCreditsPollError = null;
state.buyCreditsSettledCredits = null;
if (buyCreditsPollTimer) {
clearInterval(buyCreditsPollTimer);
buyCreditsPollTimer = null;
}
if (buyCreditsAutoCloseTimer) {
clearTimeout(buyCreditsAutoCloseTimer);
buyCreditsAutoCloseTimer = null;
}
render();
}
async function buyCreditsSelectPackage(credits) {
const n = Number(credits);
if (!Number.isFinite(n) || n <= 0) return;
state.buyCreditsLoading = true;
state.buyCreditsError = null;
render();
let checkoutWin = null;
try {
const r = await fetch("/api/credits/buy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
credits: n,
// Where BTCPay should send the buyer's browser after
// successful payment. Lands them back on the exact
// Recap page they were on instead of stuck on BTCPay's
// "Paid" screen.
return_url: window.location.href,
}),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
throw new Error(data.message || data.error || `HTTP ${r.status}`);
}
const result = data.result || {};
state.buyCreditsInvoice = {
invoiceId: result.invoice_id,
checkoutUrl: result.checkout_url,
sats: result.sats,
credits: result.credits,
// Phase 1 inline-payment fields. Present when the upstream
// relay surfaces the BOLT11 invoice in its response (new
// contract); absent when it returns the legacy shape. When
// present we render an inline QR + copy UI; when absent we
// fall back to opening BTCPay in a new tab.
bolt11: result.bolt11 || null,
lightningExpiresAt: result.lightning_expires_at || result.expires_at || null,
// Diagnostic captured by the relay when bolt11 came back
// null. Surfaced in the legacy-fallback view so operators
// can see WHY the inline path didn't light up — saves a
// round-trip through log tailing.
lnDebug: result._ln_debug || null,
};
if (state.buyCreditsInvoice.bolt11) {
// Inline path — stay on this page, render QR + invoice.
// Polling continues to drive the settle handoff.
state.buyCreditsView = "polling";
startBuyCreditsPoll(result.invoice_id);
} else {
// Legacy fallback: open BTCPay checkout in a new tab.
// Once the relay starts returning bolt11, this branch
// stops firing and the inline path takes over.
try {
checkoutWin = window.open(result.checkout_url, "_blank");
} catch {}
if (!checkoutWin) {
// Popup blocker. Same-tab navigation as a last resort.
window.location.href = result.checkout_url;
}
state.buyCreditsView = "polling";
startBuyCreditsPoll(result.invoice_id);
}
} catch (err) {
state.buyCreditsError = err.message || String(err);
state.buyCreditsView = "packages";
} finally {
state.buyCreditsLoading = false;
render();
}
}
function startBuyCreditsPoll(invoiceId) {
if (buyCreditsPollTimer) clearInterval(buyCreditsPollTimer);
state.buyCreditsPolling = true;
state.buyCreditsPollError = null;
buyCreditsPollTimer = setInterval(async () => {
try {
const r = await fetch("/api/credits/invoice/" + encodeURIComponent(invoiceId));
const data = await r.json().catch(() => ({}));
if (!r.ok) {
state.buyCreditsPollError = data.message || data.error || `HTTP ${r.status}`;
render();
return;
}
const status = data?.result?.status;
if (status === "settled") {
clearInterval(buyCreditsPollTimer);
buyCreditsPollTimer = null;
state.buyCreditsPolling = false;
const credits = data?.result?.credits || state.buyCreditsInvoice?.credits || 0;
// Refresh BOTH /api/relay/status AND /api/account/whoami so
// every code-path that reads credits (toolbar pill, mobile
// menu, settings panel) sees the new number when the modal
// auto-closes a few seconds from now.
try {
await Promise.all([
loadRelayStatus(true).catch(() => {}),
loadAccount().catch(() => {}),
]);
} catch {}
// Transition the modal to an in-place success view
// instead of immediately closing + tossing a corner
// toast. The settle moment deserves a clear visual
// beat — buyer just paid real sats, they should see
// an unambiguous confirmation before the UI moves on.
// Auto-closes after 2.8s; the Done button closes
// sooner if they want.
state.buyCreditsView = "success";
state.buyCreditsSettledCredits = credits;
render();
if (buyCreditsAutoCloseTimer) clearTimeout(buyCreditsAutoCloseTimer);
buyCreditsAutoCloseTimer = setTimeout(() => {
buyCreditsAutoCloseTimer = null;
if (state.buyCreditsOpen && state.buyCreditsView === "success") {
closeBuyCreditsModal();
}
}, 2800);
} else if (status === "expired" || status === "invalid") {
clearInterval(buyCreditsPollTimer);
buyCreditsPollTimer = null;
state.buyCreditsPolling = false;
state.buyCreditsPollError =
status === "expired"
? "Invoice expired before payment landed. Close this and try again."
: "Payment was marked invalid. Close this and try again.";
render();
}
} catch (err) {
state.buyCreditsPollError = err.message || String(err);
render();
}
}, 3000);
}
function reopenBuyCreditsCheckout() {
const inv = state.buyCreditsInvoice;
if (!inv?.checkoutUrl) return;
try { window.open(inv.checkoutUrl, "_blank"); } catch {}
}
function renderBuyCreditsModal() {
if (!state.buyCreditsOpen) return "";
let inner;
if (state.buyCreditsLoading && !state.buyCreditsInvoice) {
inner = `<div class="buy-loading">Loading bundles…</div>`;
} else if (state.buyCreditsError && state.buyCreditsView === "packages") {
inner = `
<div class="buy-error">
<strong>Couldn't open purchase.</strong>
<div style="margin-top:6px;font-size:12px;color:#fca5a5;">${escHtml(state.buyCreditsError)}</div>
<button class="buy-retry-btn" onclick="openBuyCreditsModal()">Try again</button>
</div>
`;
} else if (state.buyCreditsView === "polling") {
inner = renderBuyCreditsPollingView();
} else if (state.buyCreditsView === "success") {
inner = renderBuyCreditsSuccessView();
} else {
inner = renderBuyCreditsPackageCards();
}
// Modal width is state-aware: the tier picker needs ~1000px to
// show 3 credit packs side-by-side, but the inline-payment
// view (QR + invoice) and the success confirmation are
// centered content that should sit in a tight ~420px card.
const compact =
(state.buyCreditsView === "polling" &&
state.buyCreditsInvoice?.bolt11) ||
state.buyCreditsView === "success";
const inlinePay =
state.buyCreditsView === "polling" &&
state.buyCreditsInvoice?.bolt11;
const modalStyle = compact ? `style="width:420px;max-width:100%;"` : "";
// Header label switches to match each view's purpose.
let headerLabel = "Buy Recap credits";
if (inlinePay) headerLabel = "Pay with Lightning";
else if (state.buyCreditsView === "success") headerLabel = "Payment confirmed";
return `
<div class="buy-overlay" onclick="if(event.target===this)closeBuyCreditsModal()">
<div class="buy-modal" role="dialog" aria-modal="true" ${modalStyle}>
<div class="buy-header">
<h2>${headerLabel}</h2>
<button class="close-btn" onclick="closeBuyCreditsModal()" aria-label="Close">&times;</button>
</div>
<div class="buy-body">
${inner}
</div>
</div>
</div>
`;
}
// ── Buy-credits success view ───────────────────────────────────
// Renders once the polling loop sees `status: "settled"`. Stays
// up for ~2.8s before auto-closing — long enough for the buyer
// to register that the payment landed, short enough that the
// app doesn't feel stuck. The bouncy checkmark + radiating
// sparkles is the BTCPay-inspired moment of "yes, it worked";
// pure CSS animations so no library needed.
function renderBuyCreditsSuccessView() {
const credits = state.buyCreditsSettledCredits || 0;
// 8 sparkles evenly spaced in a circle for the burst. Each
// gets a `--ang` custom property the CSS @keyframes uses to
// translate outward in that direction. Particle emoji are
// mixed so it doesn't look too repetitive.
const particleChars = ["✨", "⚡", "✨", "⭐", "✨", "⚡", "✨", "⭐"];
const particles = particleChars
.map((ch, i) => {
const angle = (360 / particleChars.length) * i;
return `<span class="buy-sparkle" style="--ang:${angle}deg;animation-delay:${i * 30}ms;">${ch}</span>`;
})
.join("");
return `
<div class="buy-success">
<div class="buy-success-burst">
${particles}
<div class="buy-success-check">✓</div>
</div>
<h3 class="buy-success-title">Payment confirmed</h3>
<p class="buy-success-sub">
${credits} Recap credit${credits === 1 ? "" : "s"} added to your balance
</p>
<button class="buy-secondary-btn" onclick="closeBuyCreditsModal()"
style="margin-top:16px;padding:8px 18px;font-size:12px;font-weight:600;">
Done
</button>
</div>
`;
}
function renderBuyCreditsPackageCards() {
const packages = state.buyCreditsPackages || [];
if (packages.length === 0) {
return `<div class="buy-loading">No bundles available.</div>`;
}
const cards = packages.map(renderBuyCreditsCard).join("");
// Anon-buyer warning: credits are tied to this browser cookie
// until they sign up. We promise the credits transfer on signup
// (handled server-side in anon-trial.js linkToUser) so the user
// doesn't have to lose them by creating an account — just makes
// them persistent across devices + clears the "if I clear
// cookies I lose this" risk.
const isAnon = isMulti() && !state.account?.user;
const anonWarning = isAnon
? `<div style="margin-top:14px;padding:10px 12px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.30);border-radius:8px;font-size:12px;color:#c7d2fe;line-height:1.55;">
<strong style="color:#e0e7ff;">Heads up:</strong> Credits you buy now are tied to this browser. <a href="/auth.html" style="color:#a5b4fc;text-decoration:underline;">Sign up for a free Recaps account</a> (30 seconds) to save them across devices and protect them if you clear cookies.
</div>`
: "";
return `
<div class="buy-tier-grid">${cards}</div>
${anonWarning}
`;
}
function renderBuyCreditsCard(p) {
const credits = Number(p.credits);
const sats = Number(p.sats);
const perCredit = credits > 0 ? Math.round(sats / credits) : 0;
// Highlight the best per-credit value so the buyer's eye lands
// on it. Computed by sorting all packages by sats-per-credit
// ascending; first one wins.
const best = (state.buyCreditsPackages || [])
.slice()
.filter((x) => x.credits > 0)
.sort((a, b) => a.sats / a.credits - b.sats / b.credits)[0];
const isBest = !!best && best.credits === credits && best.sats === sats && (state.buyCreditsPackages || []).length > 1;
const badge = isBest ? `<span class="buy-badge">Best value</span>` : "";
return `
<div class="buy-tier ${isBest ? "buy-tier-highlighted" : ""}">
<div class="buy-tier-top">
<div class="buy-tier-name">${credits} credit${credits === 1 ? "" : "s"}</div>
<div class="buy-tier-badges">${badge}</div>
</div>
<div class="buy-price-row">
<span class="buy-price-new">${formatSats(sats)}<span class="buy-price-unit"> sats</span></span>
</div>
<div class="buy-tier-desc" style="margin-top:6px;">
${formatSats(perCredit)} sats per credit
</div>
<button class="buy-select-btn ${isBest ? "buy-select-btn-primary" : ""}"
onclick="buyCreditsSelectPackage(${credits})"
${state.buyCreditsLoading ? "disabled" : ""}>
${state.buyCreditsLoading ? "Opening…" : "⚡ Pay with Lightning"}
</button>
</div>
`;
}
function renderBuyCreditsPollingView() {
const inv = state.buyCreditsInvoice;
const sats = inv?.sats != null ? formatSats(inv.sats) : "—";
const credits = inv?.credits != null ? inv.credits : "—";
const invoiceId = inv?.invoiceId || "";
const bolt11 = inv?.bolt11 || "";
const pollError = state.buyCreditsPollError
? `<div class="buy-poll-error">${escHtml(state.buyCreditsPollError)}</div>`
: "";
// Anon-buyer recovery hint with invoice ID — same content as
// before but now appears below the QR (or below the legacy
// "Reopen checkout" buttons) instead of inline with payment.
const isAnon = isMulti() && !state.account?.user;
const invoiceIdBlock = invoiceId && isAnon
? `
<div style="margin-top:16px;padding:12px;background:rgba(15,23,42,0.6);border:1px solid #1e293b;border-radius:8px;text-align:left;">
<div style="font-size:11px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px;">
Invoice ID
</div>
<div style="display:flex;gap:8px;align-items:center;">
<code style="flex:1;font-size:11px;color:#e2e8f0;background:#0f172a;padding:8px 10px;border-radius:6px;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">${escHtml(invoiceId)}</code>
<button class="buy-secondary-btn" onclick="copyInvoiceId('${escAttr(invoiceId)}', this)"
style="flex-shrink:0;padding:8px 12px;font-size:11px;">Copy</button>
</div>
<div style="margin-top:8px;font-size:11px;color:#94a3b8;line-height:1.5;">
Save this if you plan to sign up later — paste it into <em>Settings → Claim a previous purchase</em> to move these credits to your account if they don't transfer automatically.
</div>
</div>`
: "";
// ── Inline Lightning UI (Phase 1) ──
// Compact layout: small QR (~220px), truncated single-line
// BOLT11 with copy button, primary "Open in wallet" CTA below.
// No background card around the QR — the modal IS the card.
if (bolt11) {
// Lightning URI deep link — mobile wallets (Phoenix, Muun,
// Wallet of Satoshi, Blue, etc.) register the lightning:
// scheme and launch into a "confirm payment" view when this
// URL is tapped. Desktop falls through to copy-paste path.
const lnUri = `lightning:${bolt11}`;
return `
<div style="text-align:center;max-width:280px;margin:0 auto;">
<h3 style="margin:0 0 2px;font-size:18px;">Pay ${sats} sats</h3>
<div style="font-size:11px;color:#94a3b8;margin-bottom:14px;">
${credits} credit${credits === 1 ? "" : "s"} · scan with any Lightning wallet
</div>
<div id="buy-credits-qr-mount"
style="background:#fff;padding:10px;border-radius:8px;display:inline-block;margin-bottom:12px;line-height:0;">
<div style="color:#94a3b8;font-size:11px;padding:80px 60px;line-height:1.4;">Generating QR…</div>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:10px;">
<code style="flex:1;min-width:0;font-size:11px;color:#94a3b8;background:#0a0e1a;padding:7px 9px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:ltr;text-align:left;">${escHtml(bolt11)}</code>
<button class="buy-secondary-btn" onclick="copyInvoiceId('${escAttr(bolt11)}', this)"
style="flex-shrink:0;padding:7px 12px;font-size:11px;font-weight:600;">Copy</button>
</div>
<a href="${escAttr(lnUri)}"
style="display:block;padding:10px 18px;font-size:13px;font-weight:600;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;">
⚡ Open in wallet
</a>
<div style="margin-top:10px;font-size:10px;color:#64748b;line-height:1.5;">
Updates automatically when payment lands.
</div>
${pollError}
${invoiceIdBlock}
<button onclick="closeBuyCreditsModal()"
style="margin-top:10px;background:transparent;color:#64748b;border:none;font-size:11px;text-decoration:underline;cursor:pointer;">
Cancel
</button>
</div>
`;
}
// ── Legacy external-tab fallback ──
// Fires when the relay hasn't been updated yet to surface
// bolt11 in the response. Pre-inline behavior preserved as
// a safety net for any store configuration where the LN
// destination isn't extractable via greenfield API (e.g.,
// LN not configured on the store, or a different BTCPay
// version with a yet-unseen response shape).
return `
<div class="buy-polling">
<div class="buy-polling-spinner">⏳</div>
<h3>Waiting for payment…</h3>
<p>
Your invoice for <strong>${credits} credit${credits === 1 ? "" : "s"}</strong> at
<strong>${sats} sats</strong> is open in another tab.
Once you pay over Lightning, this screen will add the credits
to your balance — usually within a few seconds.
</p>
${invoiceIdBlock}
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="reopenBuyCreditsCheckout()">Reopen checkout</button>
<button class="buy-secondary-btn" onclick="closeBuyCreditsModal()">Cancel &amp; close</button>
</div>
${pollError}
</div>
`;
}
// Post-render hook for the inline payment view — once the modal
// DOM is in place, paint the QR onto the placeholder. We can't
// do this inside the template string because qrcode-generator
// expects a real DOM node. Idempotent: if the QR is already
// there for the current invoice we no-op.
function mountBuyCreditsQr() {
const inv = state.buyCreditsInvoice;
if (!inv?.bolt11) return;
const mount = document.getElementById("buy-credits-qr-mount");
if (!mount) return;
if (mount.dataset.bolt11 === inv.bolt11) return; // already painted
if (typeof qrcode !== "function") {
// QR library failed to load (offline, blocked, etc.) — leave
// the placeholder text. BOLT11 + Open-in-wallet still work.
mount.innerHTML = `<div style="color:#64748b;font-size:12px;text-align:center;padding:24px 0;">QR unavailable — copy the invoice text below or tap "Open in wallet".</div>`;
return;
}
try {
// typeNumber: 0 = auto-detect minimum version needed.
// errorCorrectLevel: "L" = low (7% redundancy) — fine for a
// screen-displayed QR scanned from inches away. Higher levels
// bloat the matrix unnecessarily.
const qr = qrcode(0, "L");
// BOLT11 uses lowercase but wallets accept both. We uppercase
// to enable "alphanumeric" mode which packs ~37% more data per
// module — important so the QR stays scannable for longer
// invoices. Wallets normalize back to lowercase internally.
qr.addData(inv.bolt11.toUpperCase(), "Alphanumeric");
qr.make();
// Fixed-size SVG (scalable:false). At cellSize=3 a typical
// ~400-char BOLT11 lands around ~75 modules square → ~225px
// QR — fits cleanly on mobile + desktop without being huge.
// Explicit px dimensions mean we don't depend on the parent
// having a sized box (which collapsed our previous attempt).
// No outer wrapper — the parent mount element provides the
// white background + padding so the QR sits cleanly on it.
const svg = qr.createSvgTag({ cellSize: 3, margin: 2 });
mount.innerHTML = svg;
mount.dataset.bolt11 = inv.bolt11;
} catch (err) {
console.warn("[buy] QR render failed:", err);
mount.innerHTML = `<div style="color:#fca5a5;font-size:12px;text-align:center;padding:24px;background:transparent;">Couldn't render QR — copy the invoice below.</div>`;
}
}
// Paints the inline Lightning QR for the SUBSCRIBE modal's bitcoin
// invoice. Clone of mountBuyCreditsQr keyed to #subscribe-qr-mount and
// state.subscribeInvoice. Idempotent via mount.dataset.bolt11 so the
// 3s poll re-render doesn't repaint (or flicker) the QR.
function mountSubscribeQr() {
const inv = state.subscribeInvoice;
if (!inv?.bolt11) return;
const mount = document.getElementById("subscribe-qr-mount");
if (!mount) return;
if (mount.dataset.bolt11 === inv.bolt11) return; // already painted
if (typeof qrcode !== "function") {
mount.innerHTML = `<div style="color:#64748b;font-size:12px;text-align:center;padding:24px 0;">QR unavailable — copy the invoice text below or tap "Open in wallet".</div>`;
return;
}
try {
const qr = qrcode(0, "L");
qr.addData(inv.bolt11.toUpperCase(), "Alphanumeric");
qr.make();
const svg = qr.createSvgTag({ cellSize: 3, margin: 2 });
mount.innerHTML = svg;
mount.dataset.bolt11 = inv.bolt11;
} catch (err) {
console.warn("[subscribe] QR render failed:", err);
mount.innerHTML = `<div style="color:#fca5a5;font-size:12px;text-align:center;padding:24px;background:transparent;">Couldn't render QR — copy the invoice below.</div>`;
}
}
// Copy-to-clipboard for the invoice ID. Mutates the button label
// briefly so the user gets feedback without needing a toast (the
// toast layer is full of other purchase-related messages right
// now). Falls back to manual select if Clipboard API is blocked
// (rare on https, but Safari Private mode has been known to).
async function copyInvoiceId(invoiceId, btn) {
if (!invoiceId) return;
const original = btn ? btn.textContent : null;
try {
await navigator.clipboard.writeText(invoiceId);
if (btn) {
btn.textContent = "Copied ✓";
setTimeout(() => { if (btn) btn.textContent = original || "Copy"; }, 1500);
}
} catch (err) {
if (btn) {
btn.textContent = "Press & hold to copy";
setTimeout(() => { if (btn) btn.textContent = original || "Copy"; }, 2500);
}
}
}
// Same shape as escHtml but optimized for attribute values where
// we need to keep single quotes safe (inline onclick handlers).
function escAttr(s) {
if (!s) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ────────────────────────────────────────────────────────────────────
// First-visit welcome modal. Brand-new anon visitors on a multi-
// tenant Recap see this ONCE, before they figure the URL input
// out from scratch. Cookie-based dismissal means it doesn't
// re-appear on subsequent visits from the same browser. Single
// mode never shows this — single-mode installs are operator-
// initiated and don't need a "what is this product" pitch.
// ────────────────────────────────────────────────────────────────────
function shouldShowWelcome() {
// Multi-mode anon ONLY. Skip if /api/account/whoami hasn't
// resolved yet to avoid flashing the modal at single-mode
// operators between page load and the account fetch.
if (!state.welcomeOpen) return false;
if (!state.account?.loaded) return false;
if (!isMulti()) return false;
if (state.account?.user) return false; // signed-in user — they've seen the app before
return true;
}
function dismissWelcome() {
state.welcomeOpen = false;
try {
// 1-year cookie so the same browser doesn't see the welcome
// again. SameSite=Lax + Secure (StartOS tunnel terminates
// HTTPS, so this is fine in production).
const oneYear = 60 * 60 * 24 * 365;
document.cookie =
"recap_welcome_seen=1; Max-Age=" +
oneYear +
"; Path=/; SameSite=Lax; Secure";
} catch {
// Cookies disabled — the modal just won't persist its
// dismissal across reloads. Acceptable degradation.
}
render();
}
function renderWelcomeModal() {
if (!shouldShowWelcome()) return "";
return `
<div class="buy-overlay" onclick="if(event.target===this)dismissWelcome()">
<div class="buy-modal" role="dialog" aria-modal="true"
style="max-width:560px;">
<div class="buy-header">
<h2>Welcome to Recaps</h2>
<button class="close-btn" onclick="dismissWelcome()" aria-label="Close">&times;</button>
</div>
<div class="buy-body">
<p style="font-size:14px;color:#cbd5e1;line-height:1.55;margin-bottom:18px;">
Recaps turns long videos and podcasts into scannable, timestamped summaries
so you only spend time on the parts that interest you.
</p>
<ul class="welcome-bullets">
<li>
<strong>Paste any YouTube or podcast link</strong>
<span>including Apple Podcasts, Spotify, Fountain, and RSS feeds.</span>
</li>
<li>
<strong>AI-powered topic summaries with timestamps</strong>
<span>tap any time to jump to that moment.</span>
</li>
<li>
<strong>Full transcript on demand</strong>
<span>expand any topic to read what was actually said.</span>
</li>
<li>
<strong>Library, subscriptions, and auto-queue</strong>
<span>track channels + podcasts and auto-process new episodes
(Pro feature).</span>
</li>
<li>
<strong>Pay your way</strong>
<span>try without signup, top up with Bitcoin per credit,
subscribe monthly, or self-host with your own LLM keys.</span>
</li>
<li>
<strong>Find signal faster</strong>
<span>Recaps helps you spot what's worth your time and skip the rest.</span>
</li>
</ul>
<div style="margin-top:24px;display:flex;justify-content:center;">
<button class="buy-select-btn buy-select-btn-primary"
onclick="dismissWelcome()"
style="min-width:220px;">
Get started &rarr;
</button>
</div>
</div>
</div>
</div>
`;
}
// ────────────────────────────────────────────────────────────────────
// Tier signup modal — the "Sign up" pill's destination for anon
// visitors. Three cards: Free (magic-link signup, no payment),
// Pro/Max (buy + create-account-via-magic-link-after-settle).
// Pro/Max policies come from /api/license/policies which we made
// anon-accessible in v0.2.93. The Free card is a synthetic local
// card (Keysat doesn't have a "no license" policy).
// ────────────────────────────────────────────────────────────────────
let tierSignupPollTimer = null;
async function openTierSignupModal() {
state.tierSignupOpen = true;
state.tierSignupView = "cards";
state.tierSignupLoading = true;
state.tierSignupError = null;
state.tierSignupPolicies = null;
state.tierSignupEmail = "";
state.tierSignupSelectedTier = null;
state.tierSignupBusy = false;
state.tierSignupInvoice = null;
state.tierSignupPollError = null;
render();
try {
const res = await fetch(`${API_BASE}/api/license/policies`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.tierSignupPolicies = data;
state.tierSignupLoading = false;
render();
} catch (e) {
state.tierSignupError = e.message || "Couldn't load tiers.";
state.tierSignupLoading = false;
render();
}
}
function closeTierSignupModal() {
state.tierSignupOpen = false;
if (tierSignupPollTimer) {
clearInterval(tierSignupPollTimer);
tierSignupPollTimer = null;
}
render();
}
// User picked a tier card. For Free, switch to email-collection
// view. For Pro/Max, also switch — same email-collection view
// but the submit handler does a license purchase instead.
function tierSignupSelect(slug) {
state.tierSignupSelectedTier = slug;
state.tierSignupView = "email";
state.tierSignupError = null;
render();
}
function tierSignupBack() {
if (tierSignupPollTimer) {
clearInterval(tierSignupPollTimer);
tierSignupPollTimer = null;
}
state.tierSignupView = "cards";
state.tierSignupSelectedTier = null;
state.tierSignupError = null;
state.tierSignupInvoice = null;
state.tierSignupPollError = null;
render();
}
// fetchWithRetry — wraps fetch with silent retries on a transport
// failure (TypeError). iOS Safari can dispatch a request onto a
// pooled keep-alive socket the server (or a proxy in front of it)
// has already closed; for non-idempotent POSTs it surfaces a "Load
// failed" TypeError instead of transparently re-sending on a fresh
// connection. A single quick retry often reuses the same dead socket
// and fails again, so retry a few times with growing backoff to
// outlast Safari evicting the socket. Server errors (4xx/5xx) are
// returned as-is and NOT retried — those are deliberate responses.
async function fetchWithRetry(input, init) {
const backoffsMs = [400, 1200];
for (let attempt = 0; ; attempt++) {
try {
return await fetch(input, init);
} catch (err) {
if (attempt >= backoffsMs.length) throw err;
await new Promise((r) => setTimeout(r, backoffsMs[attempt]));
}
}
}
async function tierSignupSubmit() {
const email = (state.tierSignupEmail || "").trim();
const slug = state.tierSignupSelectedTier;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
state.tierSignupError = "Enter a valid email.";
render();
return;
}
state.tierSignupBusy = true;
state.tierSignupError = null;
render();
try {
if (slug === "free") {
// Free path: request-link is the standard magic-link flow.
const res = await fetchWithRetry(`${API_BASE}/auth/request-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `HTTP ${res.status}`);
}
state.tierSignupView = "free_sent";
} else {
// Pro/Max path: license purchase. On settle the server
// creates the user + attaches the license + sends a
// magic-link with celebratory copy.
const res = await fetchWithRetry(`${API_BASE}/api/license/purchase`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ policySlug: slug, buyerEmail: email }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.checkout_url) {
throw new Error(
data.message || data.error || `HTTP ${res.status}`,
);
}
const policy = (state.tierSignupPolicies?.policies || []).find(
(p) => p.slug === slug,
);
state.tierSignupInvoice = {
invoiceId: data.invoice_id,
checkoutUrl: data.checkout_url,
tierLabel: policy?.name || "Pro",
};
state.tierSignupView = "polling";
// Open BTCPay checkout in a new tab and start polling.
try { window.open(data.checkout_url, "_blank", "noopener"); } catch {}
startTierSignupPoll();
}
} catch (e) {
state.tierSignupError = e.message || "Something went wrong.";
} finally {
state.tierSignupBusy = false;
render();
}
}
function startTierSignupPoll() {
if (tierSignupPollTimer) clearInterval(tierSignupPollTimer);
tierSignupPollTimer = setInterval(pollTierSignupInvoice, 4000);
// Fire one immediate poll too, so a fast-pay buyer doesn't wait.
pollTierSignupInvoice();
}
async function pollTierSignupInvoice() {
const inv = state.tierSignupInvoice;
if (!inv?.invoiceId) return;
try {
const res = await fetch(
`${API_BASE}/api/license/poll/${encodeURIComponent(inv.invoiceId)}`,
);
const data = await res.json().catch(() => ({}));
if (data?.status === "settled") {
if (tierSignupPollTimer) {
clearInterval(tierSignupPollTimer);
tierSignupPollTimer = null;
}
state.tierSignupView = "purchase_sent";
render();
} else if (data?.status === "expired" || data?.status === "invalid") {
if (tierSignupPollTimer) {
clearInterval(tierSignupPollTimer);
tierSignupPollTimer = null;
}
state.tierSignupPollError =
"Invoice expired before payment. You can start again.";
render();
}
} catch (e) {
// Transient — keep polling; only surface a banner if the
// operator's relay or Keysat goes down for more than a few
// ticks.
state.tierSignupPollError = e.message || "Poll error";
render();
}
}
function reopenTierSignupCheckout() {
const url = state.tierSignupInvoice?.checkoutUrl;
if (url) {
try { window.open(url, "_blank", "noopener"); } catch {}
}
}
function renderTierSignupModal() {
if (!state.tierSignupOpen) return "";
let inner;
if (state.tierSignupLoading && !state.tierSignupPolicies) {
inner = `<div class="buy-loading">Loading tiers…</div>`;
} else if (state.tierSignupError && state.tierSignupView === "cards") {
inner = `
<div class="buy-error">
<strong>Couldn't load tiers.</strong>
<div style="margin-top:6px;font-size:12px;color:#fca5a5;">${escHtml(state.tierSignupError)}</div>
<button class="buy-retry-btn" onclick="openTierSignupModal()">Try again</button>
</div>`;
} else if (state.tierSignupView === "email") {
inner = renderTierSignupEmailView();
} else if (state.tierSignupView === "free_sent") {
inner = renderTierSignupSentView({ paid: false });
} else if (state.tierSignupView === "purchase_sent") {
inner = renderTierSignupSentView({ paid: true });
} else if (state.tierSignupView === "polling") {
inner = renderTierSignupPollingView();
} else {
inner = renderTierSignupCards();
}
return `
<div class="buy-overlay" onclick="if(event.target===this)closeTierSignupModal()">
<div class="buy-modal" role="dialog" aria-modal="true">
<div class="buy-header">
<h2>Create your Recaps account</h2>
<button class="close-btn" onclick="closeTierSignupModal()" aria-label="Close">&times;</button>
</div>
<div class="buy-body">
${inner}
</div>
</div>
</div>
`;
}
function renderTierSignupCards() {
// Synthetic Free card prepended; Pro/Max pulled from policies.
// Copy depends on the operator's tenant_default_credits config:
// - 0: "Your trial credits transfer to your account." (no
// signup bonus — operator's choice. Grant's setup.)
// - N>0: "N credits added at signup + your trial credits
// transfer." (signup is a credit boost.)
// Existing trial credits ALWAYS transfer via linkToUser, so we
// mention that in both cases.
const grant = state.account?.signup_grant_credits ?? 0;
const grantBullet =
grant > 0
? `<li>${grant} extra credit${grant === 1 ? "" : "s"} added at signup, no payment required</li>`
: "";
// Phrase the transfer-over bullet to be friendly even when the
// visitor doesn't have a trial cookie yet (they will after they
// click Create Account → request-link round trip → first
// /api/process call).
const transferBullet = `<li>Any trial credits you've already earned transfer to your account</li>`;
return `
<div class="buy-tier-grid">
<div class="buy-tier">
<div class="buy-tier-top">
<div class="buy-tier-name">Free</div>
</div>
<div class="buy-tier-desc">No payment required — magic-link sign-in.</div>
<div class="buy-price-row">
<span class="buy-price-new">0<span class="buy-price-unit"> sats</span></span>
</div>
<ul class="buy-bullets">
${grantBullet}
${transferBullet}
<li>Library saved to your account</li>
<li>Sign in from any device via email</li>
</ul>
<button class="buy-select-btn" onclick="tierSignupSelect('free')">
Create free account &rarr;
</button>
</div>
${(state.tierSignupPolicies?.policies || []).map(renderTierSignupCard).join("")}
</div>
`;
}
function renderTierSignupCard(p) {
const isHighlighted = !!p.highlighted;
const isRecurring = !!p.is_recurring;
const cadence = isRecurring ? cadenceSuffix(p.renewal_period_days || 30) : "";
const baseSats = typeof p.price_sats === "number" ? p.price_sats : 0;
const bullets =
Array.isArray(p.marketing_bullets) && p.marketing_bullets.length > 0
? `<ul class="buy-bullets">${p.marketing_bullets
.map((b) => `<li>${escHtml(b)}</li>`)
.join("")}</ul>`
: "";
const badge = isHighlighted
? `<span class="buy-badge">Most popular</span>`
: "";
return `
<div class="buy-tier ${isHighlighted ? "buy-tier-highlighted" : ""}">
<div class="buy-tier-top">
<div class="buy-tier-name">${escHtml(p.name || p.slug || "")}</div>
<div class="buy-tier-badges">${badge}</div>
</div>
${p.description ? `<div class="buy-tier-desc">${escHtml(p.description)}</div>` : ""}
<div class="buy-price-row">
<span class="buy-price-new">${formatSats(baseSats)}<span class="buy-price-unit"> sats${cadence}</span></span>
</div>
${bullets}
<button class="buy-select-btn ${isHighlighted ? "buy-select-btn-primary" : ""}"
onclick="tierSignupSelect('${escAttr(p.slug)}')">
Sign up + Pay &rarr;
</button>
</div>
`;
}
function renderTierSignupEmailView() {
const slug = state.tierSignupSelectedTier;
const isFree = slug === "free";
const policy = isFree
? null
: (state.tierSignupPolicies?.policies || []).find((p) => p.slug === slug);
const tierName = isFree ? "Free" : policy?.name || "Pro";
const priceLine = isFree
? "No payment — just enter your email and we'll send a sign-in link."
: `${formatSats(policy?.price_sats || 0)} sats${policy?.is_recurring ? cadenceSuffix(policy.renewal_period_days || 30) : ""}. We'll send a sign-in link to this email once payment lands.`;
const submitLabel = state.tierSignupBusy
? isFree ? "Sending…" : "Opening checkout…"
: isFree ? "Send sign-in link" : "Continue to checkout →";
const errorBlock = state.tierSignupError
? `<div class="buy-poll-error">${escHtml(state.tierSignupError)}</div>`
: "";
return `
<div style="padding:4px 2px;">
<button class="buy-back-link" onclick="tierSignupBack()">&larr; Back to tiers</button>
<h3 style="font-size:18px;font-weight:600;color:#f5f9ff;margin:14px 0 4px;">${escHtml(tierName)}</h3>
<p style="font-size:13px;color:#94a3b8;line-height:1.55;margin-bottom:18px;">${escHtml(priceLine)}</p>
<label style="display:block;font-size:12px;font-weight:600;color:#cbd5e1;margin-bottom:6px;">Email</label>
<input type="email"
value="${escAttr(state.tierSignupEmail || "")}"
oninput="state.tierSignupEmail=this.value"
onkeydown="if(event.key==='Enter')tierSignupSubmit()"
${state.tierSignupBusy ? "disabled" : ""}
style="width:100%;padding:11px 14px;font-size:15px;background:#0a0e1a;color:#f5f9ff;border:1px solid #1f2942;border-radius:8px;outline:none;font-family:inherit;-webkit-text-fill-color:#f5f9ff;-webkit-box-shadow:0 0 0 1000px #0a0e1a inset;" />
<button class="buy-select-btn buy-select-btn-primary" onclick="tierSignupSubmit()"
${state.tierSignupBusy ? "disabled" : ""}
style="margin-top:14px;width:100%;">
${submitLabel}
</button>
${errorBlock}
</div>
`;
}
function renderTierSignupPollingView() {
const inv = state.tierSignupInvoice;
const errorBlock = state.tierSignupPollError
? `<div class="buy-poll-error">${escHtml(state.tierSignupPollError)}</div>`
: "";
return `
<div class="buy-polling">
<div class="buy-polling-spinner">⏳</div>
<h3>Waiting for payment…</h3>
<p>
Your <strong>${escHtml(inv?.tierLabel || "Pro")}</strong> invoice is open in another tab.
Once you pay, we'll create your account and send a sign-in link to
<strong>${escHtml(state.tierSignupEmail)}</strong>.
</p>
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="reopenTierSignupCheckout()">Reopen checkout</button>
<button class="buy-secondary-btn" onclick="closeTierSignupModal()">Cancel &amp; close</button>
</div>
${errorBlock}
</div>
`;
}
function renderTierSignupSentView({ paid }) {
const verb = paid ? "Payment received" : "Check your email";
const detail = paid
? `Your <strong>${escHtml(state.tierSignupInvoice?.tierLabel || "Pro")}</strong> account is being created. We've sent a sign-in link to <strong>${escHtml(state.tierSignupEmail)}</strong> — your license is already attached.`
: `We've sent a sign-in link to <strong>${escHtml(state.tierSignupEmail)}</strong>. Click the link to finish creating your account. Link expires in 15 minutes.`;
return `
<div class="buy-polling" style="text-align:center;">
<div class="buy-polling-spinner">${paid ? "✓" : "📬"}</div>
<h3>${verb}</h3>
<p style="line-height:1.55;">${detail}</p>
<div class="buy-polling-actions">
<button class="buy-secondary-btn" onclick="closeTierSignupModal()">Close</button>
</div>
</div>
`;
}
function render() {
// Admin login gate (set via the StartOS "Set Admin Password"
// action) takes priority over everything: nobody — licensed or
// not — sees the activation screen or the app until they've
// signed in.
if (state.admin.loaded && state.admin.enabled && !state.admin.authed) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderAdminLoginScreen();
const pwd = document.getElementById("admin-login-password");
const usr = app.querySelector("input[autocomplete='username']");
if (state.adminLoginUsername && pwd) {
pwd.focus();
} else if (usr) {
usr.focus();
}
return;
}
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
// Show the activation screen on first launch for unlicensed users so
// they discover the upgrade path. Once they hit "Skip — use free mode"
// (which sets activationSkipped = true) they fall through to the
// normal app, which renders an upgrade banner instead.
if (!isLicensed() && !state.activationSkipped) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
savePlayerState();
const app = document.getElementById("app");
const hasResults = state.chunks.length > 0 && !state.loading;
const showSplit = hasResults || (state.loading && (state.videoId || state.currentType === "podcast"));
app.className = showSplit ? "container has-results" : "container";
// Toggle body class for sidebar layout shift
document.body.classList.toggle("history-open", state.historyOpen);
// Preserve library sidebar scroll position across full re-renders
const __prevHistoryListEl = document.querySelector(".history-list");
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
// Preserve the podcast <audio> as a LIVE node across the re-render
// so a background re-render (e.g. the ~60s relay-credit poll firing
// while a job debits credits) doesn't tear the player out of the
// DOM and stop playback. We hold the actual element here and
// re-attach it into the rebuilt tree below if the new markup wants
// the same src.
const __liveAudio = document.getElementById("podcast-audio");
// Preserve the transcript scroll position too — the rebuilt
// .chunks-scroll otherwise snaps back to the top on every render.
const __prevChunksEl = document.querySelector(".chunks-scroll");
const __prevChunksScroll = __prevChunksEl ? __prevChunksEl.scrollTop : 0;
const free = !isLicensed();
// Submit is disabled when there's no URL, or when the selected
// providers don't have any usable configuration. Relay counts as
// configured whenever the relay URL is reachable (operator-set
// at build time); per-provider keys are accepted from either
// localStorage (browser-side picker entry) or the server-side
// StartOS config. This logic replaces the legacy "must have a
// Gemini key" check which prevented Summarize when Relay was
// selected with no Gemini key entered.
const submitDisabled = !state.url.trim()
|| (!isSubscribeUrl(state.url) && !providersCanRun());
let __renderedHtml;
try {
__renderedHtml = `
<!-- Top bar: title + action icons -->
<div class="top-bar">
<div class="top-left-actions">
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="handleLibraryClick()"
title="Library">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
</div>
<div class="top-bar-input" style="position:relative;">
<button class="info-btn" type="button" onclick="toggleFormatsInfo(event)"
title="What can I recap?"
aria-label="What can I recap?">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</button>
<input type="text" class="url-input"
placeholder="${state.loading ? (free ? "Wait — free mode is one at a time" : "Paste another link to queue…") : "YouTube or podcast link…"}"
value="${escHtml(state.url)}"
oninput="state.url=this.value; updateInputMode()"
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? handleSubscribeClick() : handleSubmit() }" />
<button class="submit-btn"
onclick="${isSubscribeUrl(state.url) ? "handleSubscribeClick()" : "handleSubmit()"}"
${submitDisabled ? "disabled" : ""}
aria-label="${isSubscribeUrl(state.url) ? "Subscribe" : (state.loading ? "Queue" : "Summarize")}"
title="${isSubscribeUrl(state.url) ? "Subscribe" : (state.loading ? "Queue" : "Summarize")}"
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
<span class="submit-btn-text">${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}</span>
<span class="submit-btn-icon" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</span>
</button>
${state.formatsInfoOpen ? renderFormatsInfoCard() : ""}
</div>
${renderProcessingBreadcrumb()}
${renderToolbarStatus()}
<div class="top-actions">
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
<button class="icon-btn" onclick="toggleClipPanel()" title="Clip Collection (${state.clipCollection.length})" style="position:relative;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
</svg>
<span class="badge-count">${state.clipCollection.length}</span>
</button>
` : ""}
${hasResults ? `<button class="icon-btn" onclick="closeVideo()" title="Close video">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>` : ""}
${isAdmin() ? `<button class="icon-btn ${state.logOpen ? "active" : ""}" onclick="toggleLog()" title="Activity Log">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</button>` : ""}
<button class="icon-btn ${state.settingsOpen ? "active" : ""} ${state.ytdlpVersion === false || state.ytdlpUpdateAvailable ? "needs-attention" : ""}"
onclick="toggleSettings()" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span class="dot"></span>
</button>
</div>
<!-- Mobile hamburger menu (visible ≤600px) -->
<button class="mobile-menu-btn" onclick="toggleMobileMenu(event)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>
</svg>
${(state.ytdlpVersion === false || state.ytdlpUpdateAvailable || state.clipCollection.length > 0) ? '<span class="dot" style="display:block;"></span>' : ''}
</button>
<div class="mobile-menu-overlay ${state.mobileMenuOpen ? "open" : ""}" onclick="closeMobileMenu()"></div>
<div class="mobile-menu-dropdown ${state.mobileMenuOpen ? "open" : ""}">
${renderMobileMenuAccountSection()}
<!-- Library — the always-visible clock icon next to the URL
input is hidden on mobile to give the input + Summarize
button room. Toggle the same sidebar from here. -->
<button class="mobile-menu-item ${state.historyOpen ? "active" : ""}" onclick="closeMobileMenu(); toggleHistory()">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
</span> Library
</button>
<!-- "What can I recap?" — the desktop info icon next to
the input bar is hidden on mobile; surface the same
popover via the hamburger menu so visitors aren't
left guessing what kinds of links work. The event arg
to toggleFormatsInfo is CRITICAL — without it the
click-outside doc handler immediately closes the
popover that this very click just opened. -->
<button class="mobile-menu-item" onclick="event.stopPropagation(); closeMobileMenu(); toggleFormatsInfo(event)">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</span> What can I recap?
</button>
<div class="mobile-menu-sep"></div>
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
<button class="mobile-menu-item" onclick="closeMobileMenu(); toggleClipPanel()">
<span class="menu-icon">📎</span> Clips
<span class="menu-badge">${state.clipCollection.length}</span>
</button>
` : ""}
${hasResults ? `
<button class="mobile-menu-item" onclick="closeMobileMenu(); closeVideo()">
<span class="menu-icon">✕</span> Close Video
</button>
` : ""}
${isAdmin() ? `<button class="mobile-menu-item ${state.logOpen ? "active" : ""}" onclick="closeMobileMenu(); toggleLog()">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</span> Activity Log
</button>` : ""}
<button class="mobile-menu-item ${state.settingsOpen ? "active" : ""}" onclick="closeMobileMenu(); toggleSettings()">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</span> Settings
</button>
</div>
</div>
<!-- Mobile pizza tracker — lives as a sibling of .top-bar
(NOT inside it). The desktop copy is rendered inline in
.top-bar via renderProcessingBreadcrumb() above; iOS
Safari's flex-wrap + position:sticky combo was leaving it
invisible on phones. Hoisting the mobile copy out of the
flex container sidesteps the bug entirely. CSS swaps
which copy is visible per viewport. -->
${renderProcessingBreadcrumb("mobile")}
<!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""}
${renderBuyModal()}
${renderSubscribeModal()}
${renderBuyCreditsModal()}
${renderTierSignupModal()}
${renderWelcomeModal()}
${renderCurrentJobBanner()}
<!-- renderUpgradeBanner moved inline into the top toolbar via
renderToolbarStatus(). Old banner kept as a function for
possible future use but no longer rendered. -->
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
${state.queue.length > 0 ? renderQueue() : ""}
${state.error ? `<div class="error-box">${escHtml(state.error)}</div>` : ""}
${state.loading && (state.videoId || state.currentType === "podcast") && !state.streaming ? renderLoadingSplit() : ""}
<!-- The old centered "Processing…" spinner box (renderLoading) was
removed here — the top pizza-tracker breadcrumb already shows
the Downloading→Transcribing→Analyzing stage during the
URL-resolution window, so the box was redundant. -->
${(state.chunks.length > 0 && !state.loading) || state.streaming ? renderResults() : ""}
${!state.loading && state.error && state.videoId && state.chunks.length === 0 && state.currentType !== "podcast" ? renderErroredVideoPlaceholder() : ""}
${state.logOpen ? renderLogDrawer() : ""}
${state.historyOpen ? renderHistorySidebar() : ""}
${state.clipPanelOpen ? renderClipPanel() : ""}
`;
// Preserve the settings modal's scroll position AND suppress its
// slide-up animation across re-renders. A full render() rebuilds the
// modal DOM, which would otherwise jump it back to the top and
// replay the "flash" on every edit (e.g. setting a tenant's tier
// deep in the list). First open — when no modal existed yet —
// animates normally.
const __settingsEl = document.querySelector(".settings-modal");
const __settingsWasOpen = !!__settingsEl;
const __settingsScroll = __settingsEl ? __settingsEl.scrollTop : 0;
app.innerHTML = __renderedHtml;
if (__settingsWasOpen) {
const __m = document.querySelector(".settings-modal");
if (__m) {
__m.style.animation = "none";
__m.scrollTop = __settingsScroll;
}
}
} catch (renderErr) {
// A thrown exception inside any ${...} expression silently aborts
// the innerHTML assignment, leaving the previous DOM in place — so
// a button click that calls render() looks like a no-op. Surface
// the actual error instead of leaving the user wondering.
console.error("[render] failed to build HTML:", renderErr);
app.innerHTML = `<div class="error-box" style="margin:20px;">
<strong>Render error:</strong> ${escHtml(renderErr && renderErr.message || String(renderErr))}
<div style="font-size:11px;margin-top:6px;opacity:0.7;">
Check the browser console for the full stack trace, or share it with the developer.
</div>
</div>`;
return;
}
// Re-init player if the yt-player div exists AND is empty
// (i.e. either freshly created by this render or had its
// iframe destroyed). If the div already has an iframe child,
// the player is still mounted and rebuilding it just causes a
// black-iframe flicker — which was the symptom seen after
// Cancel mid-stream, where the render path swapped from
// renderLoadingSplit (mounted player) to renderResults (fresh
// empty div) and the destroy/recreate raced.
// Never mount into a minimized (display:none) container — creating
// the YT player there wedges the IFrame API and shows a black frame
// on expand. The expand handlers call ensureYtMounted() once visible.
const ytDiv = document.getElementById("yt-player");
const needsMount = state.videoId && ytReady && ytDiv && !ytDiv.querySelector("iframe") && !state.videoMinimized;
if (needsMount) {
setTimeout(() => initPlayer(state.videoId), 50);
}
// Re-attach the preserved live <audio> node (kept playing across
// the DOM swap) when the rebuilt markup has a matching slot, so
// playback is seamless. Detaching + synchronously re-attaching in
// the same tick sidesteps the spec's "pause on disconnect" task.
// Only when the src matches — a real navigation to a different
// episode should get the fresh element instead.
const __audioSlot = document.getElementById("podcast-audio");
if (__liveAudio && __audioSlot && __liveAudio.src === __audioSlot.src) {
__audioSlot.replaceWith(__liveAudio);
}
// Init podcast audio player if present (idempotent — skips the
// preserved node, which already carries its listeners).
if (document.getElementById("podcast-audio")) {
setTimeout(initPodcastPlayer, 50);
}
// Paint the inline Lightning QR if the buy-credits modal is in
// polling view with a bolt11 invoice. Deferred so the mount
// node is definitely in the DOM by the time we look it up.
if (state.buyCreditsOpen && state.buyCreditsView === "polling" && state.buyCreditsInvoice?.bolt11) {
setTimeout(mountBuyCreditsQr, 0);
}
// Same for the subscribe modal's inline bitcoin invoice.
if (state.subscribeOpen && state.subscribeView === "polling" && state.subscribeInvoice?.bolt11) {
setTimeout(mountSubscribeQr, 0);
}
// Restore lastSeekTarget so toggle-play still works after render
lastSeekTarget = savedLastSeekTarget;
// Restore library sidebar scroll position so deleting/editing items
// doesn't bounce the user back to the top of the list
if (state.historyOpen && __prevHistoryScroll > 0) {
const newList = document.querySelector(".history-list");
if (newList) newList.scrollTop = __prevHistoryScroll;
}
// Restore transcript scroll so a background re-render doesn't bounce
// the reader back to the top of the sections list.
if (__prevChunksScroll > 0) {
const __newChunks = document.querySelector(".chunks-scroll");
if (__newChunks) __newChunks.scrollTop = __prevChunksScroll;
}
}
function renderServerStatus() {
const s = state.serverStatus;
const lan = state.lanMode;
if (s === "connecting") {
return `<span class="server-status sleeping" title="Connecting to server..."><span class="status-dot"></span>Connecting</span>`;
}
if (s === "disconnected") {
return `<span class="server-status disconnected" title="Server is not responding. Relaunch the app to reconnect."><span class="status-dot"></span>Offline</span>`;
}
if (s === "sleeping") {
return `<span class="server-status sleeping" title="Server is sleeping. It will wake when a browser connects."><span class="status-dot"></span>Sleeping</span>`;
}
// connected
const modeLabel = lan === true ? " · Home" : lan === false ? " · Travel" : "";
const modeTitle = lan === true ? "Connected — other devices on your Wi-Fi can access" : lan === false ? "Connected — only this computer can access" : "Connected to server";
return `<span class="server-status connected" title="${modeTitle}"><span class="status-dot"></span>Connected${modeLabel}</span>`;
}
function renderLicenseBlock() {
const lic = state.license;
const tier = !isLicensed()
? { label: "Unlicensed", className: "unlicensed" }
: isProTier()
? { label: "Pro", className: "pro" }
: { label: "Core", className: "core" };
const expiresLine = lic.expiresAt
? `Expires ${new Date(lic.expiresAt).toLocaleDateString()}`
: (isLicensed() ? "Never expires" : "");
return `
<div class="license-block">
<div class="lic-row">
<strong style="font-size:12px;color:#e2e8f0;">License</strong>
<span class="lic-tier ${tier.className}">${tier.label}${lic.isTrial ? " (trial)" : ""}</span>
</div>
${isLicensed() ? `
<div class="lic-meta">
${lic.licenseId ? `<div>ID: <span class="lic-id">${escHtml(lic.licenseId.slice(0, 8))}…</span></div>` : ""}
${expiresLine ? `<div>${expiresLine}</div>` : ""}
<div>Entitlements: ${(lic.entitlements || []).map(e => escHtml(e)).join(", ") || "none"}</div>
</div>
<div class="lic-actions">
${!isProTier() ? `<button class="lic-btn" onclick="openBuyModal()" style="background:#a855f7;color:#fff;border-color:#a855f7;cursor:pointer;">Upgrade to Pro</button>` : ""}
<button class="lic-btn danger" onclick="deactivateLicense()">Deactivate</button>
</div>
` : `
<div class="lic-meta">No active license. Activate one below to unlock the app.</div>
`}
</div>
`;
}
// ── AI Providers settings block ──────────────────────────────────────────
// Small inline summary near the picker: tier name + remaining
// credits. Hidden when the operator hasn't set RELAY_BASE_URL yet
// (configured=false) since there's nothing to display. Shows a
// gentle warning when lastError is populated so the user knows
// the count is stale.
function renderRelayStatusPill() {
const rs = state.relayStatus || {};
if (!rs.configured) return ""; // relay not provisioned — hide.
const tier = rs.tier || "core";
const credits = rs.creditsRemaining;
const tierLabel = tier === "max" ? "Max" : tier === "pro" ? "Pro" : "Core";
const creditLabel =
credits == null
? "balance unknown — no relay calls yet"
: credits === Infinity || credits < 0
? "unlimited"
: `${credits} credit${credits === 1 ? "" : "s"} remaining`;
const errorBadge = rs.lastError
? `<span style="color:#f87171;font-size:10px;">&middot; ${escHtml(rs.lastError.slice(0, 80))}</span>`
: "";
// "Buy more" button — opens the credit-purchase modal. Hidden
// on the unlimited / unknown-balance branches (no scarcity to
// upsell). Anon trial visitors can also click this as of
// v0.2.90 — credits go on their cookie, transfer to their
// account on signup.
const buyMoreBtn =
credits != null && credits !== Infinity && credits >= 0
? `<button onclick="openBuyCreditsModal()"
title="Top up your Recap credits with Lightning"
style="background:rgba(99,102,241,0.20);color:#a5b4fc;border:1px solid rgba(99,102,241,0.45);padding:3px 10px;border-radius:6px;cursor:pointer;font-size:10px;font-weight:600;margin-left:auto;"
onmouseover="this.style.background='rgba(99,102,241,0.30)'"
onmouseout="this.style.background='rgba(99,102,241,0.20)'">
Buy more &rarr;
</button>`
: "";
return `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0 8px;padding:6px 10px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.30);border-radius:8px;font-size:11px;color:#cbd5e1;">
<span style="font-weight:600;color:#a5b4fc;">Relay</span>
<span>&middot; Tier: <strong style="color:#e2e8f0;">${tierLabel}</strong></span>
<span>&middot; ${creditLabel}</span>
${errorBadge}
${buyMoreBtn}
</div>
`;
}
function renderProvidersBlock() {
const tp = PROVIDER_BY_ID[state.transcriptionProvider] || PROVIDERS[0];
const ap = PROVIDER_BY_ID[state.analysisProvider] || PROVIDERS[0];
// Two-tier picker: master mode at the top (Recap Relay vs Custom
// Provider/Local), with per-step pickers shown ONLY in custom
// mode. In relay mode the per-step pickers are hidden because
// "relay" is all-or-nothing — the relay's chunked-pipeline
// endpoint does TX and AN together server-side, so there's
// nothing to configure per step.
const isRelayMode = state.providerMode === "relay";
const modeButton = (mode, label, sublabel) => {
const active = state.providerMode === mode;
return `
<button type="button" onclick="setProviderMode('${mode}')"
style="flex:1 1 0;padding:10px 12px;border-radius:8px;cursor:pointer;text-align:left;
background:${active ? "rgba(99,102,241,0.18)" : "rgba(30,41,59,0.4)"};
border:1px solid ${active ? "rgba(99,102,241,0.55)" : "#334155"};
color:${active ? "#e0e7ff" : "#cbd5e1"};">
<div style="font-size:12px;font-weight:600;">${escHtml(label)}</div>
<div style="font-size:10px;color:${active ? "#a5b4fc" : "#64748b"};margin-top:2px;">${escHtml(sublabel)}</div>
</button>
`;
};
const customPickers = isRelayMode
? ""
: `
<div style="display:flex;flex-direction:column;gap:4px;">
<span style="font-size:11px;color:#94a3b8;font-weight:600;">Transcription</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
${renderProviderSelect("transcription", NON_RELAY_TRANSCRIBE_PROVIDERS, state.transcriptionProvider)}
<span data-model-slot="transcription" style="display:flex;flex:1 1 160px;min-width:140px;">${renderModelInput("transcription", tp, state.transcriptionModel)}</span>
</div>
<span style="font-size:10px;color:#64748b;">${escHtml(tp.canTranscribe ? "Audio → text" : "(provider does not support audio — pick gemini or openai)")}</span>
</div>
<div style="display:flex;flex-direction:column;gap:4px;">
<span style="font-size:11px;color:#94a3b8;font-weight:600;">Analysis</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
${renderProviderSelect("analysis", NON_RELAY_ANALYZE_PROVIDERS, state.analysisProvider)}
<span data-model-slot="analysis" style="display:flex;flex:1 1 160px;min-width:140px;">${renderModelInput("analysis", ap, state.analysisModel)}</span>
</div>
<span style="font-size:10px;color:#64748b;">Topic structuring (text → JSON) &middot; falls back through remaining models if your chosen one fails</span>
</div>
`;
const relayModeBlurb = isRelayMode
? `
<div style="padding:8px 12px;background:rgba(99,102,241,0.08);border:1px dashed rgba(99,102,241,0.30);border-radius:6px;font-size:11px;color:#cbd5e1;line-height:1.5;">
The operator's relay handles both transcription and analysis end-to-end.
One credit per summary. Chunking + concurrency are tuned by the operator
in their relay dashboard — you don't need to configure anything.
</div>
`
: "";
return `
<label class="field-label" style="margin-top:14px;">AI Providers</label>
${renderRelayStatusPill()}
<div style="display:flex;flex-direction:column;gap:10px;border:1px solid #334155;background:rgba(30,41,59,0.3);border-radius:8px;padding:12px;">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
${modeButton("relay", "Recaps Relay", "Operator's pipeline, comped credits")}
${modeButton("custom", "Custom Provider / Local", "Bring your own API keys or run locally")}
</div>
${relayModeBlurb}
${customPickers}
<label style="display:flex;align-items:center;gap:8px;font-size:11px;color:#cbd5e1;cursor:pointer;padding-top:6px;border-top:1px solid #1e293b;">
<input type="checkbox" ${state.useYouTubeCaptions ? "checked" : ""} onchange="setUseYouTubeCaptions(this.checked)" style="margin:0;" />
<span>
Use YouTube captions when available
<span style="color:#64748b;display:block;font-size:10px;margin-top:1px;">Skips audio download + transcription. Faster, but captions don't have speaker labels — uncheck if you want them.</span>
</span>
</label>
</div>
<label class="field-label" style="margin-top:14px;display:flex;align-items:center;justify-content:space-between;">
<span>API Keys & Endpoints</span>
<button onclick="toggleShowKey()" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:11px;">${state.showKey ? "Hide values" : "Show values"}</button>
</label>
<div style="display:flex;flex-direction:column;gap:10px;border:1px solid #334155;background:rgba(30,41,59,0.3);border-radius:8px;padding:12px;">
${PROVIDERS.map(renderProviderCredentials).join("")}
<p style="font-size:10px;color:#64748b;margin:0;line-height:1.5;">
Keys typed here are saved locally in this browser. Keys set via the <strong>Set ${escHtml('<Provider>')} API Key</strong> StartOS actions are saved on the server (shared across devices); look for the green &ldquo;✓ Server-configured&rdquo; hint under a field. <strong>Delete</strong> clears both at once.
</p>
</div>
`;
}
function renderProviderSelect(pipeline, providers, selectedId) {
// pipeline = "transcription" | "analysis" — drives the change handler.
const options = providers.map((p) =>
`<option value="${escHtml(p.id)}" ${p.id === selectedId ? "selected" : ""}>${escHtml(p.name)}</option>`
).join("");
return `<select onchange="setProvider('${pipeline}', this.value)" class="key-input" style="flex:1 1 200px;min-width:160px;">${options}</select>`;
}
function renderModelInput(pipeline, provider, currentModel) {
const onchange = pipeline === "transcription"
? "setTranscriptionModel(this.value)"
: "setAnalysisModel(this.value)";
const list = pipeline === "transcription"
? resolvedTranscriptionModelsFor(provider)
: resolvedAnalysisModelsFor(provider);
if (!list || list.length === 0) {
// No list anywhere → free-text input. Happens for openai-
// compatible / ollama before the user defines their models in
// credentials and before we've fetched any from the server.
const placeholder = pipeline === "transcription"
? (provider.canTranscribe ? "model name" : "—")
: (provider.analysisModelDefault || "model name");
return `<input type="text" placeholder="${escHtml(placeholder)}"
value="${escHtml(currentModel || '')}"
oninput="${onchange}"
${!provider.canTranscribe && pipeline === "transcription" ? "disabled" : ""}
class="key-input" style="flex:1 1 160px;min-width:140px;" />`;
}
// If the saved model isn't in the resolved list, surface it as
// an extra entry so the dropdown can show what's currently
// selected (e.g. a model the user typed before defining their
// list, or a stale value from an older session).
const fullList = currentModel && !list.includes(currentModel)
? [currentModel, ...list]
: list;
const options = fullList.map((m) =>
`<option value="${escHtml(m)}" ${m === currentModel ? "selected" : ""}>${escHtml(m.replace("-preview", ""))}</option>`
).join("");
return `<select onchange="${onchange}" class="key-input" style="flex:1 1 160px;min-width:140px;">${options}</select>`;
}
// Default-expanded set: only the providers currently SELECTED for
// either pipeline (transcription or analysis). Everything else
// collapses by default — even providers that have saved
// credentials, because seeing all of them sprawled open made the
// settings panel hard to scan and obscured the active pair.
// Users can click the chevron to expand any provider on demand.
function isProviderExpandedByDefault(providerId) {
if (providerId === state.transcriptionProvider) return true;
if (providerId === state.analysisProvider) return true;
return false;
}
function isProviderExpanded(providerId) {
if (state.providerExpanded && providerId in state.providerExpanded) {
return !!state.providerExpanded[providerId];
}
return isProviderExpandedByDefault(providerId);
}
// Surgical toggle — no full render. Flips state.providerExpanded
// and mutates just the section's content + chevron icon DOM in
// place, so nothing else on the settings panel redraws.
function toggleProviderSection(providerId) {
const expanded = !isProviderExpanded(providerId);
if (!state.providerExpanded) state.providerExpanded = {};
state.providerExpanded[providerId] = expanded;
const section = document.querySelector(`[data-provider-section="${providerId}"]`);
if (!section) return;
const body = section.querySelector('[data-provider-body]');
const chevron = section.querySelector('[data-provider-chevron]');
if (body) body.style.display = expanded ? "" : "none";
if (chevron) chevron.textContent = expanded ? "▾" : "▸";
}
function renderProviderCredentials(provider) {
const opts = state.providerOpts[provider.id] || {};
const inputType = state.showKey ? "text" : "password";
const expanded = isProviderExpanded(provider.id);
let inner = `<div style="font-size:11px;color:#cbd5e1;font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:6px;cursor:pointer;" onclick="toggleProviderSection('${provider.id}')">
<span style="display:flex;align-items:center;gap:6px;">
<span data-provider-chevron style="color:#64748b;font-size:10px;width:10px;display:inline-block;">${expanded ? "▾" : "▸"}</span>
${escHtml(provider.name)}
</span>
<div style="display:flex;gap:6px;align-items:center;" onclick="event.stopPropagation()">
${renderProviderTestControl(provider)}
<span data-save-slot="${provider.id}">${renderProviderSaveControl(provider)}</span>
</div>
</div>
<div data-provider-body style="display:${expanded ? "flex" : "none"};flex-direction:column;gap:5px;">`;
if (provider.urlField) {
// If the server auto-discovered a URL for this provider (e.g.
// Ollama installed alongside us on StartOS), use it as the
// placeholder + add a hint underneath. Empty saved value will
// still let the server fall back to the discovered URL.
const discovered = discoveredUrlFor(provider.id);
const ph = discovered || provider.urlField.placeholder;
const localUrl = opts[provider.urlField.key] || "";
const urlOnServer = providerFieldOnServer(provider.id, provider.urlField.key);
inner += `
<input type="text" placeholder="${escHtml(ph)}"
value="${escHtml(localUrl)}"
oninput="setProviderOpt('${provider.id}', '${provider.urlField.key}', this.value)"
class="key-input" style="width:100%;" />`;
if (discovered) {
inner += `<div style="font-size:10px;color:#86efac;">Auto-detected on this StartOS server &mdash; leave blank to use it</div>`;
} else if (!localUrl && urlOnServer) {
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action &mdash; leave blank to use it</div>`;
}
}
if (provider.keyField) {
const t = provider.keyField.masked ? inputType : "text";
const localValue = opts[provider.keyField.key] || "";
const onServer = providerFieldOnServer(provider.id, provider.keyField.key);
inner += `
<input type="${t}" placeholder="${escHtml(provider.keyField.placeholder)}"
value="${escHtml(localValue)}"
oninput="setProviderOpt('${provider.id}', '${provider.keyField.key}', this.value)"
class="key-input" style="width:100%;" />`;
if (!localValue && onServer) {
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action &mdash; leave blank to use it</div>`;
}
}
if (provider.modelsField) {
const discoveredModels = discoveredModelsFor(provider.id);
const ph = provider.modelsField.placeholder;
const hintParts = [provider.modelsField.hint];
if (discoveredModels.length > 0) {
hintParts.push(`Detected on your server: <code style="color:#86efac;font-size:10px;">${escHtml(discoveredModels.join(", "))}</code>`);
}
inner += `
<input type="text" placeholder="${escHtml(ph)}"
value="${escHtml(opts[provider.modelsField.key] || '')}"
oninput="setProviderOpt('${provider.id}', '${provider.modelsField.key}', this.value)"
class="key-input" style="width:100%;" />
<div style="font-size:10px;color:#64748b;">${hintParts.join(" &middot; ")} &middot; <em>click Save to refresh the model dropdown above</em></div>`;
}
if (!provider.urlField && !provider.keyField) {
inner += `<div style="font-size:10px;color:#64748b;">No configuration needed.</div>`;
}
// Inline test result lands here when the user hits Test.
const test = state.providerTestResults?.[provider.id];
if (test) {
const colour = test.ok ? "#86efac" : "#fca5a5";
const icon = test.ok ? "✓" : "✗";
const body = test.ok
? `${escHtml(test.text || "(empty response)")} <span style="color:#64748b;">&middot; ${test.latencyMs}ms</span>`
: escHtml(test.error || "failed");
inner += `<div style="font-size:10px;color:${colour};margin-top:2px;">${icon} ${body}</div>`;
}
// Close the data-provider-body div opened in the header block.
inner += `</div>`;
return `<div data-provider-section="${provider.id}" style="display:flex;flex-direction:column;gap:5px;border-top:1px solid #1e293b;padding-top:8px;">${inner}</div>`;
}
// The small "Test" button + spinner shown next to each provider's
// name in the credentials section. Disabled when the provider has
// no analysis capability (i.e. nothing meaningful to test).
function renderProviderTestControl(provider) {
if (!provider.canAnalyze) return "";
const testing = state.providerTesting?.[provider.id];
if (testing) {
return `<span style="font-size:10px;color:#94a3b8;">Testing…</span>`;
}
return `<button onclick="testProvider('${provider.id}')"
style="background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer;">Test</button>`;
}
// Returns true when the provider has any user-configurable field
// (key, URL, models). Used to decide whether to render the
// Save/Delete buttons at all — providers like Relay have no
// user-editable fields (identity + URL are server-side), so the
// buttons would be no-ops.
function providerHasConfigurableFields(provider) {
return !!(provider.keyField || provider.urlField || provider.modelsField);
}
// Save button shown next to each provider's name. Click flips it
// to a green "✓ Saved" pill for ~2.5s, then back to "Save". This
// is the only place we re-render the providers block after the
// user types — keystrokes update state silently (via
// setProviderOpt, no render()) so typing doesn't flash the
// screen. Save triggers the one render needed to refresh the
// model picker dropdown above with any newly-typed model names.
function renderProviderSaveControl(provider) {
if (!providerHasConfigurableFields(provider)) return "";
const saved = state.providerSaveState?.[provider.id] === "saved";
if (saved) {
return `<span style="font-size:10px;color:#86efac;display:inline-flex;align-items:center;gap:3px;background:rgba(134,239,172,0.08);border:1px solid rgba(134,239,172,0.4);border-radius:4px;padding:2px 8px;">✓ Saved</span>`;
}
const hasAnyValue = providerHasAnyStoredValue(provider);
const deleteBtn = hasAnyValue
? `<button onclick="deleteProviderSection('${provider.id}')" title="Clear this provider's credentials from both this browser AND the server"
style="background:transparent;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:2px 10px;font-size:10px;font-weight:600;cursor:pointer;"
onmouseover="this.style.borderColor='#dc2626';this.style.color='#f87171'"
onmouseout="this.style.borderColor='#334155';this.style.color='#94a3b8'">Delete</button>`
: "";
return `${deleteBtn}<button onclick="saveProviderSection('${provider.id}')"
style="background:#1e293b;color:#cbd5e1;border:1px solid #475569;border-radius:4px;padding:2px 10px;font-size:10px;font-weight:600;cursor:pointer;margin-left:6px;">Save</button>`;
}
// Returns true if this provider has a stored value in EITHER the
// localStorage opts OR the server-side StartOS config. Drives
// whether the Delete button is visible — clicking it clears both,
// so we want to show it as long as anything is set on either side.
function providerHasAnyStoredValue(provider) {
const opts = state.providerOpts[provider.id] || {};
for (const k of Object.keys(opts)) {
if (typeof opts[k] === "string" && opts[k].trim() !== "") return true;
}
const serverFields = state.providerServerStatus?.[provider.id] || {};
for (const k of Object.keys(serverFields)) {
if (serverFields[k]) return true;
}
return false;
}
// True when the server has a non-empty value for this specific
// (providerId, fieldName) pair. Used to render the inline
// "✓ Server-configured" hint under an empty input field so the
// user can tell the provider is already wired up via the StartOS
// action even though the local input is blank.
function providerFieldOnServer(providerId, fieldName) {
const fields = state.providerServerStatus?.[providerId] || {};
return !!fields[fieldName];
}
// Delete a provider's credentials from BOTH localStorage and the
// StartOS config. Confirms first — this can't be undone (the user
// has to re-enter via the picker or re-run the StartOS action).
async function deleteProviderSection(providerId) {
const provider = PROVIDER_BY_ID[providerId];
if (!provider) return;
const proceed = confirm(
`Delete ${provider.name} credentials?\n\n` +
"This clears them from BOTH this browser AND the server. " +
"To use this provider again you'll need to re-enter them in Settings or via the StartOS \"Set " +
provider.name +
" API Key\" action."
);
if (!proceed) return;
// Local wipe
state.providerOpts[providerId] = {};
saveProviderOpts();
// Server wipe (best-effort — local is already gone if this fails)
try {
await fetch(`${API_BASE}/api/providers/${providerId}/clear`, {
method: "POST",
credentials: "same-origin",
});
} catch {}
// Pull any newly-empty server-discovered URL/models for fresh
// placeholder rendering, refresh the per-field server-config
// status so the Delete button + "✓ Server-configured" hints
// reflect the cleared state, then re-render.
await Promise.all([
loadProviderDiscovery().catch(() => {}),
loadProviderServerStatus().catch(() => {}),
]);
render();
}
// Confirms a provider's credentials by re-persisting (already
// happened on every keystroke, but defensive), flashing a green
// ✓ Saved pill for 2.5s, and triggering the one render() that
// refreshes the model-picker dropdown above with any user-defined
// models. This is the visible "save" the user sees — auto-save
// to localStorage happens silently in the background to prevent
// data loss on a stray browser refresh.
function saveProviderSection(providerId) {
saveProviderOpts();
if (!state.providerSaveState) state.providerSaveState = {};
state.providerSaveState[providerId] = "saved";
// Surgical update: swap the save-button slot for this provider
// into the green "✓ Saved" pill, and refresh the model-picker
// dropdowns at the top so any newly-typed models appear. No
// full render — typing/scroll state on the rest of the page
// stays intact (this is what the user complained about).
const slot = document.querySelector(`[data-save-slot="${providerId}"]`);
if (slot) slot.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]);
refreshModelPickersSurgical();
setTimeout(() => {
if (state.providerSaveState) delete state.providerSaveState[providerId];
const slotNow = document.querySelector(`[data-save-slot="${providerId}"]`);
if (slotNow) slotNow.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]);
}, 2500);
}
// Replace the transcription + analysis model dropdown options
// in place when the user's Models field changes. Doesn't touch
// anything else in the settings panel.
function refreshModelPickersSurgical() {
const tp = PROVIDER_BY_ID[state.transcriptionProvider] || PROVIDERS[0];
const ap = PROVIDER_BY_ID[state.analysisProvider] || PROVIDERS[0];
const tSlot = document.querySelector('[data-model-slot="transcription"]');
const aSlot = document.querySelector('[data-model-slot="analysis"]');
if (tSlot) tSlot.innerHTML = renderModelInput("transcription", tp, state.transcriptionModel);
if (aSlot) aSlot.innerHTML = renderModelInput("analysis", ap, state.analysisModel);
}
// Pings the provider with a tiny 3-word prompt. Uses whichever model
// is currently selected in the Analysis picker for that provider —
// or, if a different provider is selected analysis-side, the first
// entry from the resolved model list.
async function testProvider(providerId) {
const provider = PROVIDER_BY_ID[providerId];
if (!provider) return;
// Auto-expand this provider's section so the inline test result
// (which renders inside the body div) is visible. Without this,
// clicking Test on a collapsed section caused a "screen flash
// with no apparent answer" — the result was rendering inside
// the hidden body.
if (!state.providerExpanded) state.providerExpanded = {};
state.providerExpanded[providerId] = true;
let model = "";
if (state.analysisProvider === providerId) {
model = state.analysisModel;
}
if (!model) {
const list = resolvedAnalysisModelsFor(provider);
model = list[0] || provider.analysisModelDefault || "";
}
if (!model) {
state.providerTestResults = state.providerTestResults || {};
state.providerTestResults[providerId] = {
ok: false,
error: "No model selected. Pick or type one above.",
};
render();
return;
}
state.providerTesting = state.providerTesting || {};
state.providerTesting[providerId] = true;
state.providerTestResults = state.providerTestResults || {};
delete state.providerTestResults[providerId];
render();
try {
const res = await fetch(`${API_BASE}/api/providers/test`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId,
model,
opts: state.providerOpts[providerId] || {},
}),
});
const data = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
state.providerTestResults[providerId] = data;
} catch (e) {
state.providerTestResults[providerId] = { ok: false, error: e.message };
} finally {
state.providerTesting[providerId] = false;
render();
}
}
function setProvider(pipeline, providerId) {
const provider = PROVIDER_BY_ID[providerId];
if (!provider) return;
if (pipeline === "transcription") {
state.transcriptionProvider = providerId;
// Snap the model to a sensible default for this provider:
// catalog → user-defined Models field → server-discovered.
// This is what makes "switch to Whisper, see my Parakeet
// model name pre-filled" actually work.
const list = resolvedTranscriptionModelsFor(provider);
state.transcriptionModel = list[0] || "";
} else {
state.analysisProvider = providerId;
const list = resolvedAnalysisModelsFor(provider);
state.analysisModel = list[0] || provider.analysisModelDefault || "";
}
saveProviderSelection();
render();
}
function setTranscriptionModel(model) {
state.transcriptionModel = (model || "").trim();
saveProviderSelection();
}
function setAnalysisModel(model) {
state.analysisModel = (model || "").trim();
saveProviderSelection();
}
function setProviderOpt(providerId, field, value) {
if (!state.providerOpts[providerId]) state.providerOpts[providerId] = {};
state.providerOpts[providerId][field] = (value || "").trim();
saveProviderOpts();
}
function setUseYouTubeCaptions(checked) {
// No render() — the checkbox's visual state is already correct
// (user just clicked it), and state.useYouTubeCaptions is only
// read when submitting a URL. A full re-render here flashed the
// entire settings screen for no UI benefit.
state.useYouTubeCaptions = !!checked;
try { localStorage.setItem("recap-use-yt-captions", checked ? "1" : "0"); } catch {}
}
function renderProUpsell(featureName, description) {
return `
<div class="pro-upsell">
<div class="pro-title">${escHtml(featureName)} &middot; Pro feature</div>
<div class="pro-desc">${escHtml(description)}</div>
<button class="pro-cta" onclick="openBuyModal()" style="cursor:pointer;border:none;">Upgrade to Pro &rarr;</button>
</div>
`;
}
function renderSettingsModal() {
// ── Multi-tenant tenant view (non-admin signed-in or trial) ──────
// Operator-only blocks (provider keys, yt-dlp status, cookies,
// server-side license activation) get hidden. Tenants only see
// their account info, their credits/library status, and a Sign
// out button. The operator's view (is_admin=1) falls through to
// the full panel below.
if (isMulti() && !isAdmin()) {
return `
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
<div class="settings-modal">
<div class="settings-modal-header">
<h2>Account</h2>
<button class="close-btn" onclick="toggleSettings()">&times;</button>
</div>
<div class="settings-modal-body">
${renderTenantAccountBlock()}
${renderTenantSubscriptionBlock()}
${state.account?.user ? renderClaimPurchaseBlock() : ""}
${state.account?.user ? renderPasswordBlock() : ""}
${state.account?.user ? renderMySessionsBlock() : ""}
${state.account?.user ? renderDigestBlock() : ""}
${renderLibraryTransfer()}
${state.account?.user ? renderTenantDangerZone() : ""}
</div>
</div>
</div>
`;
}
// ── Operator / single-mode view (existing behavior) ─────────────
return `
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
<div class="settings-modal">
<div class="settings-modal-header">
<h2>Settings</h2>
<button class="close-btn" onclick="toggleSettings()">&times;</button>
</div>
<div class="settings-modal-body">
${renderLicenseBlock()}
${renderProvidersBlock()}
${renderYtdlpStatus()}
${renderCookieStatus()}
${canUseSubscriptions()
? renderSubscriptions()
: `<label class="field-label">Subscriptions</label>${renderProUpsell("Channel subscriptions", "Subscribe to YouTube channels and podcast feeds, then auto-process new uploads on a schedule. Available on the Pro tier.")}`}
${renderLibraryTransfer()}
${isMulti() && isAdmin() ? renderAdminTenantsBlock() : ""}
${isMulti() && isAdmin() ? renderAdminActivityBlock() : ""}
${state.admin.enabled && state.admin.authed ? `
<label class="field-label" style="margin-top:12px;">Admin Session</label>
<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">
<span style="font-size:11px;color:#94a3b8;line-height:1.5;">
Signed in as <strong>${escHtml(state.admin.username || "admin")}</strong>. The password is set on the server via the <strong>Set Admin Password</strong> StartOS action.
</span>
<button onclick="submitAdminLogout()" style="padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">Sign out</button>
</div>
` : ""}
</div>
</div>
</div>
`;
}
// Mobile-menu account section. Surfaces credits + signin/out on
// small screens where the desktop top-bar status pills are hidden.
// For single-mode installs this returns "" (no account ui needed
// — they're always the operator) so the menu stays tight.
function renderMobileMenuAccountSection() {
if (!isMulti()) return "";
const acct = state.account || {};
const rs = state.relayStatus || {};
const credits = rs.creditsRemaining;
// Credit display copy mirrors the desktop pill. Mobile users see
// the same "N Recap credits" string they'd see in the top bar.
let creditLine = "";
if (credits != null && credits >= 0) {
// Show the count + a Buy more item so the user can top up
// straight from the menu (matches the desktop "+ Buy more"
// affordance on the toolbar pill).
creditLine = `
<div class="mobile-menu-item" style="cursor:default;color:#cbd5e1;">
<span class="menu-icon">⚡</span>
${credits} Recap credit${credits === 1 ? "" : "s"}
</div>
<button class="mobile-menu-item" onclick="closeMobileMenu(); openBuyCreditsModal()" style="color:#cbd5e1;">
<span class="menu-icon"></span>
Buy more credits
</button>`;
} else if (credits != null && credits < 0) {
creditLine = `<div class="mobile-menu-item" style="cursor:default;color:#cbd5e1;">
<span class="menu-icon">⚡</span> Unlimited Recap credits
</div>`;
} else if (acct.state === "anonymous" && (acct.available_trial_credits || 0) > 0) {
// Anonymous visitor with no trial cookie yet — surface the
// operator-configured trial allowance so they know what's
// available before they hit Summarize. Stacked with a Buy
// more menu item so they can also pre-purchase credits
// before spending their trial (per Grant's feedback in 0.2.94:
// "Buy more should be visible whenever there's a credit count").
const n = acct.available_trial_credits;
creditLine = `
<div class="mobile-menu-item" style="cursor:default;color:#a5b4fc;">
<span class="menu-icon">⚡</span>
${n} free Recap credit${n === 1 ? "" : "s"} ready
</div>
<button class="mobile-menu-item" onclick="closeMobileMenu(); openBuyCreditsModal()" style="color:#cbd5e1;">
<span class="menu-icon"></span>
Buy more credits
</button>`;
}
// Identity row. Signed-in users see their email + Sign out.
// Anonymous / trial users see a Sign in CTA.
let identityRow = "";
if (acct.user && acct.user.email) {
const shortEmail = acct.user.email.length > 30
? acct.user.email.slice(0, 28) + "…"
: acct.user.email;
identityRow = `
<div class="mobile-menu-item" style="cursor:default;color:#cbd5e1;flex-direction:column;align-items:flex-start;gap:2px;">
<span style="font-size:11px;color:#64748b;">Signed in as</span>
<span style="font-size:13px;color:#e2e8f0;font-weight:500;">${escHtml(shortEmail)}</span>
</div>
<a href="/auth/signout" class="mobile-menu-item" style="text-decoration:none;color:#94a3b8;">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</span> Sign out
</a>`;
} else {
// Anonymous / trial visitor — two entries that mirror the
// desktop toolbar: primary "Sign up" opens the 3-tier
// (Free / Pro / Max) modal, secondary "Sign in" jumps to the
// magic-link form for returning users. Mobile previously only
// exposed "Sign in", which left new visitors without an
// obvious path to create an account.
identityRow = `
<button class="mobile-menu-item" onclick="closeMobileMenu(); openTierSignupModal()" style="color:#a5b4fc;font-weight:600;">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="7" r="4"></circle>
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"></path>
<line x1="19" y1="8" x2="19" y2="14"></line>
<line x1="22" y1="11" x2="16" y2="11"></line>
</svg>
</span> Sign up
</button>
<a href="/auth.html?intent=signin" class="mobile-menu-item" style="text-decoration:none;color:#94a3b8;">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</span> Sign in
</a>`;
}
// Upgrade entry for signed-in non-admin tenants. Three states:
// - Free → single "Upgrade" entry opens the buy modal at the
// tier picker so the user can compare Pro vs Max
// side-by-side (they may not know the difference
// yet — forcing them to pick blind from the menu
// would be a worse UX).
// - Pro → single "Upgrade to Max" entry opens the modal
// pre-selected on Max (Pro is the only thing to
// upgrade FROM, so the destination is unambiguous).
// - Max → no entry (already at top tier).
// Anon/trial users get the sign-up flow instead, handled in
// the identity row below.
let upgradeRows = "";
if (acct.user && !acct.user.is_admin) {
if (hasEntitlement("max")) {
// already top tier — nothing to upgrade
} else if (hasEntitlement("pro")) {
upgradeRows = `
<button class="mobile-menu-item" onclick="closeMobileMenu(); openBuyModal('max')" style="color:#fde68a;font-weight:600;">
<span class="menu-icon">★</span>
Upgrade to Max
</button>`;
} else {
upgradeRows = `
<button class="mobile-menu-item" onclick="closeMobileMenu(); openBuyModal()" style="color:#c4b5fd;font-weight:600;">
<span class="menu-icon">★</span>
Upgrade
</button>`;
}
}
return `
${creditLine}
${upgradeRows}
${identityRow}
<div class="mobile-menu-sep"></div>
`;
}
// ── Admin: Tenants list block ─────────────────────────────────────
// Operator-only. Lists every signed-up user with their balance,
// license status, and inline "+ N credits" / "Revoke sessions"
// actions. Lazy-loaded — only fetched when the settings modal
// opens AND the operator is multi-mode admin.
function renderAdminTenantsBlock() {
const ops = state.ops;
const tenants = ops.tenants;
let body = "";
if (ops.tenantsLoading && tenants == null) {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Loading tenants…</div>`;
} else if (ops.tenantsError) {
body = `<div style="font-size:12px;color:#fca5a5;padding:6px 0;">Couldn't load tenants: ${escHtml(ops.tenantsError)}</div>`;
} else if (Array.isArray(tenants) && tenants.length === 0) {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">No tenants yet. The first email to sign up becomes the operator account (already you).</div>`;
} else if (Array.isArray(tenants)) {
body = tenants.map((t) => renderTenantRow(t)).join("");
} else {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Tap Refresh to load.</div>`;
}
return `
<label class="field-label" style="margin-top:14px;display:flex;align-items:center;justify-content:space-between;">
<span>Tenants${Array.isArray(tenants) ? ` (${tenants.length})` : ""}</span>
<button onclick="loadAdminTenants()"
style="font-size:11px;font-weight:600;padding:4px 10px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">
${ops.tenantsLoading ? "Loading…" : "Refresh"}
</button>
</label>
<div class="tenants-list" style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:6px;display:flex;flex-direction:column;gap:4px;max-height:340px;overflow:auto;">
${body}
</div>
`;
}
function renderTenantRow(t) {
const balance = typeof t.balance === "number" ? t.balance : 0;
// Subscription tier (relay-owned; cached on the Recaps account). The
// primary status indicator now that the Keysat license is decoupled.
const tierVal = (t.tier || "core").toLowerCase();
const tierStyles = {
max: "background:rgba(234,179,8,0.15);color:#fde68a;border:1px solid rgba(234,179,8,0.4);",
pro: "background:rgba(168,85,247,0.15);color:#d8b4fe;border:1px solid rgba(168,85,247,0.4);",
core: "background:rgba(100,116,139,0.12);color:#94a3b8;border:1px solid rgba(100,116,139,0.35);",
};
const tierBadge = `<span style="font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;${tierStyles[tierVal] || tierStyles.core}">${tierVal.toUpperCase()}</span>`;
// (Removed the "LIC" badge — a leftover pre-decoupling Keysat license
// is ignored entirely now that tier is the sole source of paid status,
// so surfacing it was just confusing noise.)
const adminBadge = t.is_admin
? `<span style="font-size:9px;font-weight:700;padding:2px 6px;background:rgba(99,102,241,0.15);color:#a5b4fc;border:1px solid rgba(99,102,241,0.4);border-radius:4px;">ADMIN</span>`
: "";
const created = t.created_at
? new Date(t.created_at).toLocaleDateString()
: "—";
const lastSeen = t.last_signin_at
? new Date(t.last_signin_at).toLocaleDateString()
: "never";
const ipLine = t.signup_ip
? `<span style="color:#475569;">· ${escHtml(t.signup_ip)}</span>`
: "";
const sessions = t.session_count || 0;
const grantOpen = state.ops.grantOpenFor === t.id;
const grantForm = grantOpen
? `
<div style="display:flex;gap:6px;align-items:center;padding:6px 8px;background:rgba(99,102,241,0.06);border-top:1px solid rgba(99,102,241,0.2);">
<input type="number" min="1" max="100000" placeholder="Amount"
value="${escHtml(state.ops.grantAmount)}"
oninput="state.ops.grantAmount=this.value"
onkeydown="if(event.key==='Enter')submitGrantCredits('${escHtml(t.id)}')"
style="flex:1;padding:6px 10px;font-size:12px;background:#0a0e1a;color:#e2e8f0;border:1px solid #334155;border-radius:6px;outline:none;" />
<button onclick="submitGrantCredits('${escHtml(t.id)}')"
${state.ops.grantBusy ? "disabled" : ""}
style="padding:6px 12px;font-size:11px;font-weight:600;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;${state.ops.grantBusy ? "opacity:0.5;cursor:not-allowed;" : ""}">
${state.ops.grantBusy ? "..." : "Grant"}
</button>
<button onclick="toggleGrantCreditsRow('${escHtml(t.id)}')"
style="padding:6px 10px;font-size:11px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Cancel
</button>
</div>`
: "";
// Inline tier selector — three buttons, current tier highlighted.
// Writes the relay-owned subscription tier (and the local cache) via
// POST /api/admin/tenants/:id/tier. The operator's own admin row has
// no selector (admins are the operator, not a subscription).
const tierOpen = state.ops.tierOpenFor === t.id;
const tierChoice = (val, label, color) =>
`<button onclick="setTenantTier('${escHtml(t.id)}','${val}')"
${state.ops.tierBusy ? "disabled" : ""}
style="flex:1;padding:6px 10px;font-size:11px;font-weight:600;background:${tierVal === val ? color : "#1e293b"};color:${tierVal === val ? "#fff" : "#cbd5e1"};border:1px solid #334155;border-radius:6px;cursor:${state.ops.tierBusy ? "not-allowed" : "pointer"};${state.ops.tierBusy ? "opacity:0.5;" : ""}">${label}</button>`;
const tierForm = tierOpen
? `
<div style="display:flex;gap:6px;align-items:center;padding:6px 8px;background:rgba(168,85,247,0.06);border-top:1px solid rgba(168,85,247,0.2);">
${tierChoice("core", "Core", "#64748b")}
${tierChoice("pro", "Pro", "#a855f7")}
${tierChoice("max", "Max", "#eab308")}
</div>`
: "";
return `
<div class="tenant-row" style="background:#0f172a;border:1px solid #1e293b;border-radius:6px;overflow:hidden;flex-shrink:0;">
<div style="padding:10px 12px;">
<div style="font-size:13px;color:#e2e8f0;font-weight:500;display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0;">
<span style="word-break:break-all;min-width:0;">${escHtml(t.email || "(no email)")}</span>
${adminBadge}
${t.is_admin ? "" : tierBadge}
</div>
<div style="font-size:10.5px;color:#64748b;margin-top:4px;line-height:1.5;">
${balance} credit${balance === 1 ? "" : "s"} · ${sessions} session${sessions === 1 ? "" : "s"} · joined ${created} · last seen ${lastSeen} ${ipLine}
</div>
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;justify-content:flex-end;">
<button onclick="toggleGrantCreditsRow('${escHtml(t.id)}')"
title="Grant additional credits to this user"
style="padding:6px 10px;font-size:11px;font-weight:600;background:${grantOpen ? "#334155" : "#1e293b"};color:#cbd5e1;border:1px solid #334155;border-radius:6px;cursor:pointer;">
${grantOpen ? "Close" : "+ Credits"}
</button>
${!t.is_admin && state.ops.operatorKeyConfigured ? `<button onclick="toggleTierRow('${escHtml(t.id)}')"
title="Set this user's subscription tier (Core / Pro / Max)"
style="padding:6px 10px;font-size:11px;font-weight:600;background:${tierOpen ? "#334155" : "#1e293b"};color:#cbd5e1;border:1px solid #334155;border-radius:6px;cursor:pointer;">
${tierOpen ? "Close" : "Tier"}
</button>` : ""}
${sessions > 0 ? `<button onclick="revokeAllSessionsForTenant('${escHtml(t.id)}', '${escHtml((t.email || "").replace(/'/g, "\\'"))}')"
title="Sign this user out of every active session"
style="padding:6px 10px;font-size:11px;font-weight:600;background:#1e293b;color:#fca5a5;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Sign out
</button>` : ""}
${!t.is_admin ? `<button onclick="adminDeleteTenant('${escHtml(t.id)}', '${escHtml((t.email || "").replace(/'/g, "\\'"))}')"
title="Delete this user and their library"
style="padding:6px 10px;font-size:11px;font-weight:600;background:#1e293b;color:#fca5a5;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Delete
</button>` : ""}
</div>
</div>
${grantForm}
${tierForm}
</div>
`;
}
// ── Admin: Recent Activity block ─────────────────────────────────
// Surfaces the IP/UA/hour aggregations from /api/admin/recent-signups.
// The window selector (24h / 7d / 14d) refetches; everything else is
// a passive read-only summary the operator skims for anomalies.
function renderAdminActivityBlock() {
const ops = state.ops;
const a = ops.activity;
const hoursOptions = [
{ v: 24, label: "24h" },
{ v: 24 * 7, label: "7d" },
{ v: 24 * 14, label: "14d" },
];
const windowSelector = hoursOptions
.map(
(o) => `<button onclick="loadAdminActivity(${o.v})"
style="padding:4px 10px;font-size:11px;font-weight:600;border:1px solid #334155;border-radius:6px;cursor:pointer;background:${ops.activityHours === o.v ? "#3b82f6" : "#1e293b"};color:${ops.activityHours === o.v ? "#fff" : "#94a3b8"};">${o.label}</button>`,
)
.join("");
let body = "";
if (ops.activityLoading && a == null) {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Loading activity…</div>`;
} else if (ops.activityError) {
body = `<div style="font-size:12px;color:#fca5a5;padding:6px 0;">Couldn't load activity: ${escHtml(ops.activityError)}</div>`;
} else if (a) {
const total = a.totals || {};
const ipRows = (a.signups_by_ip || []).slice(0, 10);
const linkIpRows = (a.magic_links_by_ip || []).slice(0, 10);
const renderIpList = (rows, kind) =>
rows.length === 0
? `<div style="font-size:11px;color:#64748b;padding:6px 0;">No ${kind} in this window.</div>`
: rows
.map(
(r) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 8px;background:#0f172a;border:1px solid #1e293b;border-radius:6px;margin-bottom:4px;font-size:11px;">
<span style="color:#e2e8f0;font-family:ui-monospace,monospace;">${escHtml(r.ip || "—")}</span>
<span style="color:#94a3b8;">${r.count} ${kind === "magic-link requests" ? `· ${r.distinct_emails || 0} emails` : ""}</span>
</div>`,
)
.join("");
body = `
<div style="display:flex;gap:12px;padding:10px 12px;background:#0f172a;border:1px solid #1e293b;border-radius:8px;margin-bottom:10px;flex-wrap:wrap;">
<div style="flex:1;min-width:100px;">
<div style="font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;">Signups</div>
<div style="font-size:22px;color:#e2e8f0;font-weight:600;">${total.signups || 0}</div>
</div>
<div style="flex:1;min-width:100px;">
<div style="font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;">Magic-link requests</div>
<div style="font-size:22px;color:#e2e8f0;font-weight:600;">${total.magic_link_requests || 0}</div>
</div>
</div>
<div style="margin-bottom:8px;">
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:6px;">Signups by IP (top 10)</div>
${renderIpList(ipRows, "signups")}
</div>
<div>
<div style="font-size:11px;color:#94a3b8;font-weight:600;margin-bottom:6px;">Magic-link requests by IP (top 10) — high counts with few signups are sus</div>
${renderIpList(linkIpRows, "magic-link requests")}
</div>
`;
} else {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Tap a window above to load.</div>`;
}
return `
<label class="field-label" style="margin-top:14px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;">
<span>Recent Activity</span>
<div style="display:flex;gap:4px;">${windowSelector}</div>
</label>
<div style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:10px;">
${body}
</div>
`;
}
// ── Tenant: Claim a previous purchase ────────────────────────────
// Self-service recovery for the cookie-jar edge case. If an anon
// visitor bought credits in Safari Private mode (or any browser
// where the magic-link click lands in a different cookie jar than
// the purchase tab), their anon trial cookie is lost when they
// sign up — so linkToUser has nothing to transfer. The BTCPay
// invoice ID is the user's proof-of-purchase: paste it here and
// the server verifies settled status with the relay and credits
// their account. Only shown to signed-in users.
function renderClaimPurchaseBlock() {
if (!state.account?.user) return "";
const cp = state.claimPurchase || {};
const submitting = !!cp.submitting;
const resultBlock = cp.success
? `<div style="margin-top:8px;padding:8px 10px;background:rgba(74,222,128,0.10);border:1px solid rgba(74,222,128,0.30);border-radius:6px;font-size:12px;color:#bbf7d0;">✓ Added ${cp.success} credit${cp.success === 1 ? "" : "s"} to your account.</div>`
: cp.error
? `<div style="margin-top:8px;padding:8px 10px;background:rgba(252,165,165,0.10);border:1px solid rgba(252,165,165,0.30);border-radius:6px;font-size:12px;color:#fecaca;">${escHtml(cp.error)}</div>`
: "";
return `
<label class="field-label" style="margin-top:14px;">Claim a previous purchase</label>
<div style="font-size:11px;color:#94a3b8;line-height:1.55;margin-bottom:8px;">
Bought credits anonymously and they didn't transfer to your account?
Paste the invoice ID from your purchase (shown after payment or in your BTCPay receipt email).
</div>
<div style="display:flex;gap:6px;align-items:stretch;">
<input id="claim-purchase-input" type="text" placeholder="invoice ID"
autocomplete="off" autocapitalize="off" spellcheck="false"
style="flex:1;min-width:0;padding:8px 10px;background:#0f172a;border:1px solid #334155;border-radius:6px;color:#e2e8f0;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;" />
<button onclick="submitClaimPurchase()" ${submitting ? "disabled" : ""}
style="padding:8px 14px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:${submitting ? "wait" : "pointer"};white-space:nowrap;">
${submitting ? "Claiming…" : "Claim"}
</button>
</div>
${resultBlock}
`;
}
async function submitClaimPurchase() {
const input = document.getElementById("claim-purchase-input");
const invoiceId = (input?.value || "").trim();
if (!invoiceId) return;
state.claimPurchase = { submitting: true };
render();
try {
const r = await fetch("/api/credits/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoice_id: invoiceId }),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
state.claimPurchase = {
submitting: false,
error: data.message || data.error || `HTTP ${r.status}`,
};
} else {
state.claimPurchase = {
submitting: false,
success: data.credits || 0,
};
// Refresh balance everywhere so the user immediately sees
// the new credit count without closing settings.
try {
await Promise.all([
loadRelayStatus(true).catch(() => {}),
loadAccount().catch(() => {}),
]);
} catch {}
}
} catch (err) {
state.claimPurchase = {
submitting: false,
error: err.message || String(err),
};
}
render();
}
// ── Tenant: My Sessions block ────────────────────────────────────
// Lite-settings view for non-admin signed-in users — see their own
// active sessions and revoke any but the current one. Trial users
// get nothing here (they have no sessions row in SQLite).
function renderMySessionsBlock() {
if (!state.account?.user) return "";
const ms = state.mySessions;
let body = "";
if (ms.loading && ms.rows == null) {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Loading…</div>`;
} else if (ms.error) {
body = `<div style="font-size:12px;color:#fca5a5;padding:6px 0;">Couldn't load sessions.</div>`;
} else if (Array.isArray(ms.rows) && ms.rows.length === 0) {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">No active sessions.</div>`;
} else if (Array.isArray(ms.rows)) {
body = ms.rows
.map((s) => {
const isCurrent = s.id === ms.currentId;
const ua = s.user_agent || "Unknown device";
const shortUa = ua.length > 60 ? ua.slice(0, 58) + "…" : ua;
const last = s.last_used_at
? new Date(s.last_used_at).toLocaleString()
: "—";
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;background:#0f172a;border:1px solid #1e293b;border-radius:6px;margin-bottom:4px;gap:8px;">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;color:#e2e8f0;">${escHtml(shortUa)} ${isCurrent ? '<span style="font-size:10px;font-weight:700;color:#86efac;margin-left:6px;">CURRENT</span>' : ''}</div>
<div style="font-size:10.5px;color:#64748b;">${s.ip_address ? escHtml(s.ip_address) + ' · ' : ''}last used ${last}</div>
</div>
${!isCurrent ? `<button onclick="revokeMySession('${escHtml(s.id)}')" style="padding:5px 10px;font-size:11px;font-weight:600;background:#1e293b;color:#fca5a5;border:1px solid #334155;border-radius:6px;cursor:pointer;">Revoke</button>` : ''}
</div>`;
})
.join("");
} else {
body = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Tap Refresh to load.</div>`;
}
const otherCount = Array.isArray(ms.rows)
? ms.rows.filter((s) => s.id !== ms.currentId).length
: 0;
return `
<label class="field-label" style="margin-top:14px;display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span>Active Sessions</span>
<button onclick="loadMySessions()"
style="font-size:11px;font-weight:600;padding:4px 10px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">
${ms.loading ? "Loading…" : "Refresh"}
</button>
</label>
<div style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:8px;">
${body}
${otherCount > 0 ? `<button onclick="revokeOtherSessions()"
style="margin-top:8px;width:100%;padding:8px 12px;font-size:12px;font-weight:600;background:#1e293b;color:#fca5a5;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Sign out of all other devices (${otherCount})
</button>` : ''}
</div>
`;
}
// Daily Digest opt-in toggle. Off by default; a single switch that
// POSTs /api/account/digest. enabled is null until loaded — show a
// muted "Loading…" rather than a misleading unchecked box during the
// round-trip so the user never sees the wrong initial state.
function renderDigestBlock() {
const d = state.digest || {};
const checkboxAttrs =
d.enabled === null || d.loading || d.saving ? "disabled" : "";
return `
<label class="field-label" style="margin-top:14px;">Daily digest</label>
<div style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:12px;">
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#cbd5e1;cursor:${checkboxAttrs ? "default" : "pointer"};">
<input type="checkbox" ${d.enabled ? "checked" : ""} ${checkboxAttrs}
onchange="setDigestEnabled(this.checked)" style="margin:2px 0 0;" />
<span>
Email me a daily digest
<span style="color:#64748b;display:block;font-size:10px;margin-top:2px;line-height:1.5;">
${d.enabled === null
? "Loading…"
: "A once-a-day email summarizing the recaps you added to your library in the last 24 hours. Off by default; skipped on days with nothing new."}
</span>
</span>
</label>
</div>
`;
}
// Danger Zone — sits at the very bottom of the tenant lite-settings
// modal. One action for now (Delete Account); future destructive
// self-actions land here. Visually muted by default to avoid being
// a target for accidental clicks; the confirm-then-type-DELETE flow
// catches the rest.
function renderTenantDangerZone() {
return `
<label class="field-label" style="margin-top:14px;color:#fca5a5;">Danger Zone</label>
<div style="border:1px solid rgba(248,113,113,0.30);background:rgba(127,29,29,0.10);border-radius:8px;padding:14px;">
<div style="font-size:12px;color:#cbd5e1;line-height:1.55;margin-bottom:10px;">
Delete your Recaps account, library, and active sessions. Your relay-side credit pool (if you're a paying customer) stays intact at the relay — let your license expire naturally or contact support.
</div>
<button onclick="deleteMyAccount()"
style="padding:8px 14px;font-size:12px;font-weight:600;background:transparent;color:#fca5a5;border:1px solid rgba(248,113,113,0.45);border-radius:6px;cursor:pointer;">
Delete account
</button>
</div>
`;
}
// ── Tenant: Subscription / Plan block ────────────────────────────
// Replaces renderLicenseBlock() in the tenant view of the lite
// settings panel. Shows:
// - Plan badge (Pro / Max / Free)
// - Expires date + days remaining with renewal urgency
// - "Renew" CTA (always visible for licensed users; emphasized
// when expiry is < 14 days)
// - "Take Recap home" subsection for licensed tenants — fetches
// and reveals the raw LIC1- key with copy-to-clipboard
//
// We keep renderLicenseBlock() for the operator/single-mode view
// because it surfaces the right actions (Activate / Deactivate) for
// the install-wide /data/license.txt. Tenants don't have that file
// — their license lives on users.keysat_license in SQLite and
// attaches/detaches automatically via the buy flow.
function renderTenantSubscriptionBlock() {
const lic = state.license || {};
const licensed = isLicensed();
const trial = !!state.account?.trial;
const tier = !licensed
? trial ? { label: "Trial", cls: "core" } : { label: "Free", cls: "core" }
: (state.account?.user?.has_license || lic.entitlements?.includes("max")) && lic.entitlements?.includes("max")
? { label: "Max", cls: "pro" }
: { label: "Pro", cls: "pro" };
// Days-until-expiry computation. Negative means already expired
// (the user is still seeing the licensed view because the relay
// hasn't re-checked yet — rare race, but defensive).
let daysLeft = null;
let expiringSoon = false;
let expired = false;
if (lic.expiresAt) {
const ms = new Date(lic.expiresAt).getTime() - Date.now();
daysLeft = Math.ceil(ms / (24 * 60 * 60 * 1000));
expiringSoon = daysLeft <= 14 && daysLeft > 0;
expired = daysLeft <= 0;
}
const expiresLine = (() => {
if (!licensed) return "";
if (!lic.expiresAt) return "Never expires.";
const date = new Date(lic.expiresAt).toLocaleDateString();
if (expired) {
return `<span style="color:#fca5a5;">Expired ${date}</span>`;
}
if (expiringSoon) {
return `<span style="color:#fbbf24;">Renews ${date} · ${daysLeft} day${daysLeft === 1 ? "" : "s"} left</span>`;
}
return `Renews ${date} · ${daysLeft} day${daysLeft === 1 ? "" : "s"} left`;
})();
// CTA copy. Pre-paid users see "Upgrade" — clicking opens the
// buy modal which shows BOTH Pro and Max side-by-side, so
// labeling the pill "Upgrade to Pro" would have been misleading
// (you're not committing to Pro by clicking, you're opening a
// comparison). Pro users (not Max) see "Upgrade to Max" since
// the destination is unambiguous. Paid users always see
// "Renew" — purple/emphasized when close to expiry.
let ctaLabel;
let ctaPreselect = null;
if (licensed) {
ctaLabel = "Renew";
} else if (hasEntitlement("pro") && !hasEntitlement("max")) {
ctaLabel = "Upgrade to Max";
ctaPreselect = "max";
} else {
ctaLabel = "Upgrade";
}
const ctaStyle = !licensed || expiringSoon || expired
? "background:#a855f7;color:#fff;border-color:#a855f7;"
: "background:#1e293b;color:#a5b4fc;border-color:#334155;";
return `
<label class="field-label">Plan</label>
<div class="ytdlp-status" style="flex-direction:column;align-items:stretch;gap:10px;border-color:#334155;background:rgba(30,41,59,0.3);padding:14px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;">
<div>
<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px;">Tier</div>
<div style="display:flex;align-items:center;gap:8px;">
<span class="lic-tier ${tier.cls}" style="font-size:12px;">${tier.label}</span>
${expiresLine ? `<span style="font-size:12px;color:#94a3b8;">${expiresLine}</span>` : ""}
</div>
</div>
<button onclick="openBuyModal(${ctaPreselect ? `'${ctaPreselect}'` : ""})"
style="padding:8px 16px;font-size:12px;font-weight:600;border-radius:8px;border:1px solid;cursor:pointer;${ctaStyle}">
${ctaLabel}
</button>
</div>
${licensed ? renderTakeHomePanel() : ""}
</div>
`;
}
// ── Take Recap home — license-key reveal + copy ──────────────────
// Collapsed by default; one-click reveal calls /api/account/license-key
// and shows the LIC1- string with a copy button. Hidden for unlicensed
// tenants (no key to share). Lives inside renderTenantSubscriptionBlock.
function renderTakeHomePanel() {
const th = state.takeHome || {};
let inner = "";
if (!th.revealed) {
inner = `
<div style="font-size:12px;color:#cbd5e1;line-height:1.55;margin-bottom:10px;">
Run Recaps on your own StartOS server with the same license. Your subscription stays active on both your cloud account and your install.
</div>
<button onclick="revealLicenseKey()"
style="padding:8px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#a5b4fc;border:1px solid #334155;border-radius:8px;cursor:pointer;">
Get my license key
</button>`;
} else if (th.loading) {
inner = `<div style="font-size:12px;color:#94a3b8;padding:6px 0;">Loading…</div>`;
} else if (th.error) {
inner = `<div style="font-size:12px;color:#fca5a5;padding:6px 0;">${escHtml(th.error)}</div>
<button onclick="state.takeHome.revealed=false;state.takeHome.error=null;render();"
style="margin-top:6px;padding:6px 12px;font-size:11px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">Close</button>`;
} else if (th.licenseKey) {
inner = `
<div style="font-size:12px;color:#cbd5e1;line-height:1.55;margin-bottom:8px;">
Paste this on your self-hosted Recaps (Settings → I have a key):
</div>
<div style="font-family:ui-monospace,monospace;font-size:11.5px;color:#e2e8f0;background:#0a0e1a;border:1px solid #334155;border-radius:8px;padding:10px 12px;word-break:break-all;margin-bottom:8px;">
${escHtml(th.licenseKey)}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button onclick="copyLicenseKey()"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;">
${th.copied ? "Copied ✓" : "Copy key"}
</button>
<button onclick="state.takeHome.revealed=false;state.takeHome.copied=false;render();"
style="padding:6px 14px;font-size:12px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Hide
</button>
</div>
<div style="font-size:11px;color:#64748b;margin-top:10px;line-height:1.5;">
Treat this like a password — anyone with the key can use your subscription's credit pool until it expires.
</div>`;
}
return `
<div style="border-top:1px solid #334155;padding-top:12px;margin-top:6px;">
<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px;">
Take Recaps Home
</div>
${inner}
</div>
`;
}
async function revealLicenseKey() {
state.takeHome.revealed = true;
state.takeHome.loading = true;
state.takeHome.error = null;
render();
try {
const res = await fetch(`${API_BASE}/api/account/license-key`);
if (res.status === 404) throw new Error("No active license yet.");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.takeHome.licenseKey = data.license_key;
} catch (e) {
state.takeHome.error = e.message || "Couldn't load license key.";
} finally {
state.takeHome.loading = false;
render();
}
}
async function copyLicenseKey() {
const key = state.takeHome?.licenseKey;
if (!key) return;
try {
await navigator.clipboard.writeText(key);
state.takeHome.copied = true;
render();
setTimeout(() => {
state.takeHome.copied = false;
render();
}, 1800);
} catch {
showToast("Couldn't copy — select and copy manually", "!", 3000);
}
}
// Tenant-view account block — replaces the full operator settings
// panel for non-admin signed-in users (and trial visitors) on a
// multi-tenant Recap. Shows email + remaining credits + sign-out.
function renderTenantAccountBlock() {
const acct = state.account || {};
const user = acct.user || null;
const trial = acct.trial || null;
let bodyHtml = "";
if (user) {
const emailLine = user.email
? `<div style="font-size:13px;color:#e2e8f0;font-weight:500;">${escHtml(user.email)}</div>`
: "";
bodyHtml = `
${emailLine}
<div style="font-size:11px;color:#94a3b8;margin-top:4px;">
${user.has_license ? "Pro account" : "Free account"}
</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<a href="/auth/signout"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;text-decoration:none;display:inline-block;"
onmouseover="this.style.background='#334155';this.style.color='#e2e8f0'"
onmouseout="this.style.background='#1e293b';this.style.color='#94a3b8'">Sign out</a>
</div>`;
} else if (trial) {
const remaining = trial.credits_remaining ?? 0;
// Sign up routes through the 3-tier modal (Free / Pro / Max)
// — same affordance as the toolbar Sign up pill — so the
// trial visitor sees the full pricing menu instead of being
// dropped on the magic-link form (which only handles the
// Free path). closeSettings runs first so the modal swap
// is clean (no double overlay flash).
bodyHtml = `
<div style="font-size:13px;color:#e2e8f0;font-weight:500;">Free trial</div>
<div style="font-size:11px;color:#94a3b8;margin-top:4px;">
${remaining} of ${trial.credits_total} trial credit${trial.credits_total === 1 ? "" : "s"} remaining
</div>
<div style="font-size:11px;color:#cbd5e1;margin-top:8px;line-height:1.5;">
Sign up free to get more credits and save your summaries to a library.
</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<button onclick="toggleSettings(); openTierSignupModal()"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;">Sign up</button>
<a href="/auth.html?intent=signin"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;text-decoration:none;display:inline-block;">Sign in</a>
</div>`;
} else {
// Not-signed-in, no trial cookie. Same dual CTA as the trial
// branch — Sign up opens the 3-tier modal so the visitor
// sees pricing options before committing.
bodyHtml = `
<div style="font-size:13px;color:#e2e8f0;font-weight:500;">Not signed in</div>
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;">
<button onclick="toggleSettings(); openTierSignupModal()"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;">Sign up</button>
<a href="/auth.html?intent=signin"
style="padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;text-decoration:none;display:inline-block;">Sign in</a>
</div>`;
}
return `
<label class="field-label">Account</label>
<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:4px;border-color:#334155;background:rgba(30,41,59,0.3);padding:14px;">
${bodyHtml}
</div>
`;
}
// ── Library Transfer ──────────────────────────────────────────────────
function renderLibraryTransfer() {
return '<label class="field-label" style="margin-top:12px;">Library Transfer</label>' +
'<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.5;">Export your full library (summaries, folders, subscriptions) to transfer between devices, or import a library from another instance.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' +
'<button onclick="exportLibrary()" style="padding:6px 14px;font-size:12px;font-weight:600;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#4f46e5\'" onmouseout="this.style.background=\'#6366f1\'">Export Library</button>' +
'<label style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#334155\';this.style.color=\'#e2e8f0\'" onmouseout="this.style.background=\'#1e293b\';this.style.color=\'#94a3b8\'">' +
'Import Library' +
'<input type="file" accept=".json" style="display:none" onchange="importLibrary(this.files[0])">' +
'</label>' +
'</div>' +
'<div id="library-transfer-result"></div>' +
'</div>';
}
async function exportLibrary() {
try {
const res = await fetch(`${API_BASE}/api/library/export`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "recap-library.json";
a.click();
URL.revokeObjectURL(url);
showToast("Library exported!", "✓");
} catch (e) {
showToast("Export failed: " + e.message, "!");
}
}
async function importLibrary(file) {
if (!file) return;
const resultEl = document.getElementById("library-transfer-result");
try {
if (resultEl) resultEl.innerHTML = '<span style="color:#fbbf24;font-size:12px;">Importing...</span>';
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch(`${API_BASE}/api/library/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
if (result.error) throw new Error(result.error);
if (resultEl) resultEl.innerHTML = '<span style="color:#4ade80;font-size:12px;">Imported ' + result.imported + ' sessions (' + result.skipped + ' already existed)</span>';
await loadHistory();
render();
showToast("Library imported: " + result.imported + " sessions added", "✓");
} catch (e) {
if (resultEl) resultEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Import failed: ' + escHtml(e.message) + '</span>';
showToast("Import failed: " + e.message, "!");
}
}
// ── Subscriptions ──────────────────────────────────────────────────────
// Update the submit button and subscribe prompt without a full re-render
function updateInputMode() {
const btn = document.querySelector(".top-bar-input .submit-btn");
const promptEl = document.getElementById("subscribe-prompt");
const isCh = isSubscribeUrl(state.url);
if (btn) {
if (isCh) {
btn.textContent = state.addingSubLoading ? "Subscribing..." : "Subscribe";
btn.disabled = !state.url.trim() || state.addingSubLoading;
btn.style.background = "#6366f1";
btn.onclick = () => addSubscriptionFromInput();
} else {
btn.textContent = state.loading ? "Queue" : "Summarize";
// Match the full-render submit-disabled rule exactly so the
// button doesn't flicker between "enabled (full render)" and
// "disabled (surgical update)". providersCanRun() returns
// true when both the selected transcription and analysis
// providers have usable config (relay-configured, an API
// key in localStorage, server-side config, or auto-detected
// Ollama).
btn.disabled = !state.url.trim() || !providersCanRun();
btn.style.background = "";
btn.onclick = () => handleSubmit();
}
}
if (isCh && !promptEl) {
const topBar = document.querySelector(".top-bar");
if (topBar) {
const div = document.createElement("div");
div.id = "subscribe-prompt";
div.innerHTML = renderSubscribePrompt();
topBar.parentNode.insertBefore(div, topBar.nextSibling);
}
} else if (!isCh && promptEl) {
promptEl.remove();
}
}
function renderSubscribePrompt() {
const todayStr = new Date().toISOString().slice(0, 10);
// Subscriptions are a Pro-tier entitlement. Anon visitors and
// free signed-in users see the "channel detected" banner but
// with an upgrade nudge instead of the controls — clicking
// Subscribe used to 401/403 with a raw error string. The
// /api/subscriptions POST stays auth-gated on the server; this
// is purely UX.
const canSub = hasEntitlement("subscriptions");
if (!canSub) {
// Different CTA depending on signed-in vs anon. Anon: open
// the tier signup modal (Pro is one of the cards). Signed-in
// free: open the buy-license modal (existing renderBuyModal).
const isAnon = isMulti() && !state.account?.user;
const ctaLabel = isAnon ? "Sign up for Pro" : "Upgrade to Pro";
const ctaHandler = isAnon ? "openTierSignupModal()" : "openBuyModal()";
return `
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 12px; margin-top:8px; padding:10px 12px;
background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.15);
border-radius:10px;">
<span style="font-size:16px">📡</span>
<span style="font-size:12px; color:#a78bfa; font-weight:500; flex:1; min-width:160px;">
Channel detected — Pro account required to subscribe + auto-process new episodes
</span>
<button onclick="${ctaHandler}"
style="padding:6px 12px; font-size:11px; font-weight:600;
background:#a855f7; color:#fff; border:none; border-radius:6px; cursor:pointer; white-space:nowrap;"
onmouseover="this.style.background='#9333ea'"
onmouseout="this.style.background='#a855f7'">
${ctaLabel} &rarr;
</button>
</div>
`;
}
return `
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 12px; margin-top:8px; padding:10px 12px;
background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.15);
border-radius:10px;">
<span style="font-size:16px">📡</span>
<span style="font-size:12px; color:#a78bfa; font-weight:500; flex:1; min-width:160px;">
Channel detected — subscribe to track new videos
</span>
<label style="font-size:10px; color:#64748b; white-space:nowrap;">Since:</label>
<input type="date" value="${escHtml(state.addingSubSince || todayStr)}"
oninput="state.addingSubSince=this.value"
title="Only process videos uploaded on or after this date"
style="padding:5px 6px; font-size:11px;
border:1px solid rgba(139,92,246,0.2); border-radius:6px; outline:none;
background:#0f172a; color:#94a3b8; cursor:pointer;" />
<label style="display:inline-flex; align-items:center; gap:6px; font-size:11px; color:${state.addingSubAuto ? "#fbbf24" : "#94a3b8"}; cursor:pointer; white-space:nowrap;"
title="When ON, new videos from this subscription bypass the approval queue and start processing immediately.">
<input type="checkbox" ${state.addingSubAuto ? "checked" : ""}
onchange="state.addingSubAuto=this.checked; render()"
style="accent-color:#fbbf24; cursor:pointer;" />
⚡ Auto-process new videos
</label>
</div>
`;
}
async function addSubscriptionFromInput() {
const url = state.url.trim();
if (!url) return;
const podcast = isPodcastUrl(url);
state.addingSubLoading = true;
render();
try {
const auto = !!state.addingSubAuto;
const res = await fetch(`${API_BASE}/api/subscriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
since: state.addingSubSince || undefined,
type: podcast ? "podcast" : undefined,
autoDownload: auto,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to add subscription");
}
const label = podcast ? url.split("/").pop() || "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
state.url = "";
state.addingSubSince = "";
state.addingSubAuto = false;
state.addingSubLoading = false;
await loadSubscriptions();
render();
showToast(
`Subscribed to ${label}${auto ? " (auto-process ON)" : ""} — checking for ${podcast ? "episodes" : "videos"}...`,
podcast ? "🎙" : (auto ? "⚡" : "📡")
);
} catch (e) {
state.error = e.message;
state.addingSubLoading = false;
render();
}
}
// Single background poll loop — runs every 10s, surgical DOM updates only
let bgPollRunning = false;
function startBgPoll() {
if (bgPollRunning) return;
bgPollRunning = true;
setInterval(async () => {
try {
// Operator-managed in multi mode — non-admin tenants neither poll
// nor display the subscription queue (server 403s them anyway).
if (!canUseSubscriptions()) return;
const oldSubs = JSON.stringify(state.subscriptions);
const oldQueueLen = state.queue.length;
await loadSubscriptions();
const subsChanged = JSON.stringify(state.subscriptions) !== oldSubs;
// Check server auto-queue for new pending items
const res = await fetch(`${API_BASE}/api/auto-queue`);
const data = await res.json();
const items = data.items || [];
for (const item of items) {
if (state.queue.find(q => q.url === item.url)) continue;
state.queue.push({
id: item.id, url: item.url,
videoId: item.videoId || "", title: item.title || "",
uploadDate: item.uploadDate || null,
type: item.type || "youtube",
status: "pending_approval", error: null,
fromSubscription: item.subscriptionName,
});
}
const queueChanged = state.queue.length !== oldQueueLen;
// Surgical DOM updates — no full render
if (subsChanged) {
for (const sub of state.subscriptions) {
const el = document.querySelector(`[data-sub-id="${sub.id}"] .sub-name`);
if (el && el.textContent !== sub.name) el.textContent = sub.name;
const metaEl = document.querySelector(`[data-sub-id="${sub.id}"] .sub-meta`);
if (metaEl) {
const sinceDate = new Date(sub.createdAt).toLocaleDateString();
metaEl.innerHTML = `Since ${sinceDate}${sub.lastChecked ? " · Checked " + timeAgo(sub.lastChecked) : ""}${sub.paused ? " · Paused" : ""}`;
}
}
}
if (queueChanged) {
const newCount = state.queue.length - oldQueueLen;
if (newCount > 0) {
showToast(`${newCount} new video${newCount > 1 ? "s" : ""} ready for review`, "🎬");
}
const queueContainer = document.querySelector(".queue-section");
if (queueContainer) {
const temp = document.createElement("div");
temp.innerHTML = renderQueue();
const newQueue = temp.querySelector(".queue-section");
if (newQueue) queueContainer.replaceWith(newQueue);
} else {
render();
}
}
} catch {}
}, 10000);
}
async function loadSubscriptions() {
try {
const res = await fetch(`${API_BASE}/api/subscriptions`);
const data = await res.json();
state.subscriptions = data.subscriptions || [];
state.subsLoaded = true;
} catch {
state.subscriptions = [];
}
}
async function checkSubscriptionsNow() {
const btn = document.getElementById("check-subs-btn");
if (btn) { btn.disabled = true; btn.textContent = "Checking..."; }
showToast("Checking subscriptions for new videos...", "📡");
state.subCheckLog = [{ msg: "Starting subscription check..." }];
updateSubCheckLogUI();
try {
await fetch(`${API_BASE}/api/subscriptions/check-now`, { method: "POST" });
} catch {}
// Poll for completion and show log
const pollLog = async () => {
try {
const res = await fetch(`${API_BASE}/api/sub-check-log`);
const data = await res.json();
if (data.log.length > 0) {
state.subCheckLog = data.log;
updateSubCheckLogUI();
}
const autoRes = await fetch(`${API_BASE}/api/auto-queue`);
const autoData = await autoRes.json();
if (autoData.checkRunning) {
setTimeout(pollLog, 2000);
} else {
if (btn) { btn.disabled = false; btn.textContent = "Check for new videos now"; }
if (data.autoQueueCount > 0) {
showToast(`${data.autoQueueCount} video(s) pending approval`, "🎬");
} else {
showToast("No new videos found", "✓");
}
}
} catch {}
};
setTimeout(pollLog, 3000);
}
function updateSubCheckLogUI() {
const logEl = document.getElementById("sub-check-log");
if (!logEl) return;
if (state.subCheckLog.length > 0) {
logEl.style.display = "block";
logEl.innerHTML = state.subCheckLog.map(l => `<div style="font-size:11px;color:#94a3b8;padding:1px 0;font-family:'SF Mono',Menlo,monospace;">${escHtml(l.msg)}</div>`).join("");
logEl.scrollTop = logEl.scrollHeight;
}
}
async function addSubscription() {
const url = state.addingSubUrl.trim();
if (!url) return;
const podcast = isPodcastUrl(url);
state.addingSubLoading = true;
render();
try {
const res = await fetch(`${API_BASE}/api/subscriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, since: state.addingSubSince || undefined, type: podcast ? "podcast" : undefined }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to add subscription");
}
const handle = podcast ? "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
state.addingSubUrl = "";
state.addingSubSince = "";
state.addingSubLoading = false;
await loadSubscriptions();
render();
showToast(`Subscribed to ${handle} — checking for videos...`, "📡");
} catch (e) {
state.error = e.message;
state.addingSubLoading = false;
render();
}
}
async function removeSubscription(id) {
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}`, { method: "DELETE" });
if (!res.ok) {
console.error("Failed to delete subscription:", await res.text());
return;
}
state.subscriptions = state.subscriptions.filter(s => s.id !== id);
render();
} catch (err) {
console.error("Error deleting subscription:", err);
}
}
async function updateSubSince(id, dateStr) {
if (!dateStr) return;
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/since`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ since: dateStr }),
});
if (!res.ok) return;
const updated = await res.json();
const sub = state.subscriptions.find(s => s.id === id);
if (sub) sub.createdAt = updated.createdAt;
render();
showToast(`Updated since date — check for videos to pull historical content`, "📅");
} catch {}
}
async function togglePauseSubscription(id) {
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/pause`, { method: "PUT" });
const updated = await res.json();
const sub = state.subscriptions.find(s => s.id === id);
if (sub) sub.paused = updated.paused;
render();
} catch {}
}
async function toggleAutoDownload(id) {
const sub = state.subscriptions.find(s => s.id === id);
if (!sub) return;
const next = !sub.autoDownload;
// Optimistic update
sub.autoDownload = next;
render();
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/auto-download`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: next }),
});
const data = await res.json();
if (data && data.subscription) sub.autoDownload = !!data.subscription.autoDownload;
render();
} catch {
// Roll back on failure
sub.autoDownload = !next;
render();
}
}
function renderSubscriptions() {
const todayStr = new Date().toISOString().slice(0, 10);
return `
<div class="sub-section">
<label class="field-label">Subscriptions</label>
<div class="sub-add-row">
<input type="text" placeholder="YouTube channel/playlist URL or podcast RSS feed..."
value="${escHtml(state.addingSubUrl)}"
oninput="state.addingSubUrl=this.value; this.closest('.sub-add-row').querySelector('.sub-add-btn').disabled=!this.value.trim()"
onkeydown="if(event.key==='Enter')addSubscription()"
style="flex:2" />
<input type="date" value="${escHtml(state.addingSubSince || todayStr)}"
oninput="state.addingSubSince=this.value"
title="Only process videos uploaded on or after this date"
style="flex:0 0 auto; padding:9px 8px; font-size:11px;
border:1px solid #1e293b; border-radius:8px; outline:none;
background:#0f172a; color:#94a3b8; cursor:pointer;" />
<button class="sub-add-btn" onclick="addSubscription()"
${!state.addingSubUrl.trim() || state.addingSubLoading ? "disabled" : ""}>
${state.addingSubLoading ? "Adding..." : "Subscribe"}
</button>
</div>
<p style="font-size:10px; color:#475569; margin:-4px 0 8px;">Date = only process videos/episodes published on or after this date. Defaults to today.</p>
${state.subscriptions.length === 0
? `<div class="sub-empty">No subscriptions yet. Add a channel URL or podcast RSS feed.</div>`
: state.subscriptions.map(sub => {
const sinceDate = new Date(sub.createdAt).toLocaleDateString();
const sinceIso = new Date(sub.createdAt).toISOString().slice(0, 10);
return `
<div class="sub-item ${sub.paused ? "paused" : ""}" data-sub-id="${sub.id}">
<span class="sub-icon">${sub.paused ? "\u23F8" : (sub.type === "podcast" ? "\uD83C\uDFA7" : "\uD83D\uDCE1")}</span>
<div class="sub-info">
<div class="sub-name">${escHtml(sub.name)}</div>
<div class="sub-meta">
<span class="sub-since-link" onclick="event.stopPropagation(); this.nextElementSibling.showPicker?.()" title="Click to change date" style="cursor:pointer; text-decoration:underline dotted; text-underline-offset:2px;">Since ${sinceDate}</span><input type="date" value="${sinceIso}" style="position:absolute;opacity:0;width:0;height:0;pointer-events:none;" onchange="updateSubSince('${sub.id}', this.value)" />
${sub.lastChecked ? " · Checked " + timeAgo(sub.lastChecked) : ""}
${sub.paused ? " · Paused" : ""}
${sub.autoDownload ? ' · <span style="color:#fbbf24">\u26A1 Auto-process</span>' : ""}
</div>
</div>
<div class="sub-actions">
<button class="sub-action" onclick="event.stopPropagation(); toggleAutoDownload('${sub.id}')"
title="${sub.autoDownload ? "Auto-process: ON — new videos skip the queue and process immediately. Click to disable." : "Auto-process: OFF — new videos require approval in the queue. Click to enable auto-processing."}"
style="${sub.autoDownload ? "color:#fbbf24" : ""}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="${sub.autoDownload ? "#fbbf24" : "none"}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
</button>
<button class="sub-action" onclick="event.stopPropagation(); togglePauseSubscription('${sub.id}')"
title="${sub.paused ? "Resume" : "Pause"}">
${sub.paused ? "\u25B6" : "\u23F8"}
</button>
<button class="sub-action danger" onclick="event.stopPropagation(); removeSubscription('${sub.id}')"
title="Unsubscribe">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
}).join("")
}
${state.subscriptions.length > 0 ? `
<button id="check-subs-btn" onclick="checkSubscriptionsNow()" style="
margin-top: 10px; padding: 7px 14px; font-size: 11px; font-weight: 600;
background: none; color: #64748b; border: 1px solid #1e293b; border-radius: 6px;
cursor: pointer; transition: all 0.15s; width: 100%;
" onmouseover="this.style.background='#1e293b';this.style.color='#94a3b8'"
onmouseout="this.style.background='none';this.style.color='#64748b'">
Check for new videos now
</button>
<div id="sub-check-log" tabindex="0" onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='a'){event.preventDefault();var r=document.createRange();r.selectNodeContents(this);var s=window.getSelection();s.removeAllRanges();s.addRange(r)}" style="${state.subCheckLog.length > 0 ? '' : 'display:none;'} margin-top:8px; padding:8px 10px; background:#0a0e17; border:1px solid #1e293b; border-radius:8px; max-height:200px; overflow-y:auto; user-select:text; outline:none;">
${state.subCheckLog.map(l => `<div style="font-size:11px;color:#94a3b8;padding:1px 0;font-family:'SF Mono',Menlo,monospace;">${escHtml(l.msg)}</div>`).join("")}
</div>
` : ""}
</div>
`;
}
// ── Auto-queue polling ────────────────────────────────────────────────────
// Fetch pending items from server auto-queue into frontend state.
// Returns true if new items were added.
async function pollAutoQueue() {
try {
const res = await fetch(`${API_BASE}/api/auto-queue`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return false;
let added = false;
for (const item of items) {
if (state.queue.find(q => q.url === item.url)) continue;
state.queue.push({
id: item.id, url: item.url,
videoId: item.videoId || "", title: item.title || "",
uploadDate: item.uploadDate || null,
type: item.type || "youtube",
status: "pending_approval", error: null,
fromSubscription: item.subscriptionName,
});
added = true;
}
return added;
} catch { return false; }
}
// When processing errors out mid-stream, state.loading flips to
// false and the loading-split view (which had the YouTube embed +
// progress bar) unmounts — leaving just the error box and a blank
// panel. This branch puts the embed back so the user can still
// click through to watch on YouTube, and offers a one-click retry.
function renderErroredVideoPlaceholder() {
return `
<div class="results-split">
<div class="results-left">
<div class="video-embed">
<div id="yt-player"></div>
</div>
${renderWatchOnYouTubeLink()}
${state.videoTitle ? `<div class="video-title">${escHtml(state.videoTitle)}</div>` : ""}
</div>
<div class="results-right">
<div style="padding:18px;display:flex;flex-direction:column;gap:10px;align-items:flex-start;">
<div style="font-size:13px;color:#fca5a5;font-weight:600;">Processing failed before summary was ready.</div>
<div style="font-size:12px;color:#94a3b8;line-height:1.5;">
You can still watch the video using the link below the player, or try again — sometimes a different model in the Analysis dropdown gets through when one is overloaded.
</div>
<button onclick="processUrl(${JSON.stringify(state.url || "https://www.youtube.com/watch?v=" + state.videoId)})"
style="background:#6366f1;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;">
Try again
</button>
</div>
</div>
</div>
`;
}
function renderLoadingSplit() {
const steps = [
{ num: 1, label: "Download", icon: "\u2B07" },
{ num: 2, label: "Transcribe", icon: "\uD83C\uDFA4" },
{ num: 3, label: "Analyze", icon: "\uD83E\uDDE0" },
];
const isPod = state.currentType === "podcast";
if (isPod) {
return `
<div style="padding: 0 4px;">
<div style="display:flex; align-items:center; gap:10px; padding:12px 0 8px; border-bottom:1px solid #1e293b; margin-bottom:12px;">
<span style="font-size:20px;">🎙</span>
<div style="flex:1; min-width:0;">
<div style="font-size:14px; font-weight:600; color:#e2e8f0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(state.videoTitle || "Podcast Episode")}</div>
<div style="font-size:11px; color:#64748b;">Processing podcast audio...</div>
</div>
</div>
<div class="chunks-scroll" style="max-height: calc(100vh - 200px);">
${[1,2,3,4,5,6].map(i => `
<div class="skeleton-chunk" style="animation-delay:${i * 0.1}s">
<div class="skeleton-line title" style="animation-delay:${i * 0.15}s"></div>
<div class="skeleton-line subtitle" style="animation-delay:${i * 0.15 + 0.05}s"></div>
<div class="skeleton-line short" style="animation-delay:${i * 0.15 + 0.1}s"></div>
</div>
`).join("")}
</div>
</div>`;
}
return `
<div class="results-split">
<div class="results-left">
<div class="video-embed">
<div id="yt-player"></div>
</div>
${renderWatchOnYouTubeLink()}
</div>
<div class="results-right">
<div class="chunks-scroll">
${[1,2,3,4,5,6].map(i => `
<div class="skeleton-chunk" style="animation-delay:${i * 0.1}s">
<div class="skeleton-line title" style="animation-delay:${i * 0.15}s"></div>
<div class="skeleton-line subtitle" style="animation-delay:${i * 0.15 + 0.05}s"></div>
<div class="skeleton-line short" style="animation-delay:${i * 0.15 + 0.1}s"></div>
</div>
`).join("")}
</div>
</div>
</div>
`;
}
function renderLoading() {
// Pre-results loading state \u2014 shown while we don't yet have a
// video id or podcast type set (resolver still running, or
// YouTube URL we haven't extracted the id from yet). Pizza
// tracker at the top of the page handles staging; we only need
// the spinner + status text here. The old in-line pipeline
// pills duplicated the same info and were removed in 0.2.88.
return `
<div class="loading">
<div class="spinner"></div>
<p class="status-text">${escHtml(state.status || "Processing...")}</p>
</div>
`;
}
// Surgical update of the chunks-scroll DOM content (called from
// the sections_partial SSE handler). Skips the full render() so
// the YouTube iframe outside this container isn't destroyed +
// re-mounted on every window completion — which was the
// root cause of the per-window video-embed flicker the operator
// reported. Also updates the "(N / M windows complete)" indicator
// text without rebuilding its parent container.
//
// Layout invariant: only ONE .chunks-scroll exists in the DOM at
// any time (the renderer picks ONE branch in renderResults). We
// query for the first match and ignore the rest.
function applyStreamingChunksUpdate() {
const scroll = document.querySelector(".chunks-scroll");
if (!scroll) {
// Chunks container hasn't rendered yet — fall back to full
// render() so renderResults() can mount it.
render();
return;
}
const stillAnalyzing = state.streaming && state.chunks.length === 0;
// Streaming indicator below the topics list. Tiny + dim so
// it reads as subtle metadata, not a banner. 10px / muted
// slate / 70% opacity. Inline so no CSS rule can override.
// Browser cache caveat: if it ever shows up larger than this,
// hard-refresh — the spec-side renderer is locked at 10px.
const streamingIndicator = state.streaming
? `<div class="streaming-indicator" style="padding:7px 14px; text-align:center; color:#94a3b8; font-size:12px !important; line-height:1.4; opacity:0.95; letter-spacing:0.02em;">⟳ Analyzing window ${state.streamWindowsDone}${state.streamWindowsTotal ? " / " + state.streamWindowsTotal : ""}…</div>`
: "";
const chunksHtml = state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("");
const placeholder = state.chunks.length === 0 && stillAnalyzing
? `<div style="padding:24px 14px; text-align:center; color:#64748b; font-size:12px;">Topics will appear here as the model analyzes each section…</div>`
: "";
// Preserve scroll position across the innerHTML swap so the
// operator's "I was reading the third section" view doesn't
// jump back to the top each time a new section streams in.
const prevScrollTop = scroll.scrollTop;
scroll.innerHTML = chunksHtml + placeholder + streamingIndicator;
scroll.scrollTop = prevScrollTop;
// Also update the segments/topics counter line in the results
// header (if present) — non-critical, ignored if not in DOM.
updateResultsHeaderCounts();
// And update the top-bar pizza tracker if it's visible.
updateProcessingBreadcrumb();
}
// Update the "topics · segments · total" header counts in the
// results pane. Read by applyStreamingChunksUpdate after the
// chunks-scroll innerHTML swap. Best-effort — silently skips
// when the header isn't in the DOM (e.g. mobile-collapsed view).
function updateResultsHeaderCounts() {
const headerEl = document.querySelector("[data-results-header-counts]");
if (!headerEl) return;
const totalEntries = state.chunks.reduce((sum, c) => sum + c.entries.length, 0);
const lastChunk = state.chunks[state.chunks.length - 1];
const lastEntry = lastChunk ? lastChunk.entries[lastChunk.entries.length - 1] : null;
const totalDuration = lastEntry ? lastEntry.offset : 0;
headerEl.textContent =
state.chunks.length + " topics · " +
formatTime(totalDuration) + " total";
}
// ── Top-bar processing breadcrumb (pizza tracker) ─────────────
// 4-stage indicator mirroring the relay dashboard's renderBreadcrumb
// style. Shows the current pipeline stage of an in-flight job in
// the top row alongside the toolbar pills. Only visible when
// state.streaming === true; hidden otherwise. Stage is derived
// from state.currentStep + state.status text:
// - currentStep 1 = Downloading (relay/own download)
// - currentStep 2 = Transcribing (relay or local STT)
// - currentStep 3 = Analyzing (chunks streaming in)
// - currentStep complete (state.streaming false + chunks exist) = Done
function getProcessingStage() {
if (state.error) return -1;
if (!state.streaming && !state.loading) {
return state.chunks.length > 0 ? 4 : 0;
}
const step = state.currentStep || 0;
if (step <= 1) return 1; // Downloading
if (step === 2) return 2; // Transcribing
if (step >= 3) return 3; // Analyzing
return 1;
}
function renderProcessingBreadcrumb(variant) {
if (!state.streaming && !state.loading) return "";
const stage = getProcessingStage();
const isError = stage === -1;
const labels = ["Downloading", "Transcribing", "Analyzing", "Done"];
const cells = labels.map((label, i) => {
const id = i + 1;
let dotColor = "#64748b";
let textColor = "#64748b";
let pulse = "";
if (isError && i === 0) {
dotColor = "#f87171"; textColor = "#f87171";
} else if (!isError && id < stage) {
dotColor = "#4ade80"; textColor = "#94a3b8";
} else if (!isError && id === stage) {
dotColor = stage === 4 ? "#4ade80" : "#818cf8";
textColor = "#e2e8f0";
if (stage !== 4) pulse = "animation: top-breadcrumb-pulse 1.2s infinite;";
}
const dot = `<span style="color:${dotColor};font-size:11px;${pulse}">●</span>`;
const text = `<span style="color:${textColor};font-size:10.5px;font-weight:600;">${escHtml(label)}</span>`;
const arrow = i < labels.length - 1
? ` <span style="margin:0 5px; color:#475569;">→</span> `
: "";
return dot + " " + text + arrow;
}).join("");
// Two copies: the inline-toolbar one (desktop) lives inside
// .top-bar and is hidden on phones; the mobile copy lives as a
// sibling below .top-bar so it doesn't have to fight the flex-
// wrap / position:sticky interaction that was leaving it
// invisible on iOS Safari. Same cells, distinct ids for the
// in-place updater.
const id = variant === "mobile" ? "top-breadcrumb-mobile" : "top-breadcrumb";
const cls = variant === "mobile" ? "top-breadcrumb top-breadcrumb-mobile" : "top-breadcrumb";
return `<div class="${cls}" id="${id}">${cells}</div>`;
}
// Called by applyStreamingChunksUpdate to refresh the breadcrumb
// without a full render(). Replaces the inner content of BOTH the
// desktop and mobile copies (whichever happen to be in the DOM).
function updateProcessingBreadcrumb() {
["top-breadcrumb", "top-breadcrumb-mobile"].forEach((targetId) => {
const el = document.getElementById(targetId);
if (!el) return;
const variant = targetId === "top-breadcrumb-mobile" ? "mobile" : undefined;
const newHtml = renderProcessingBreadcrumb(variant);
if (!newHtml) {
el.style.display = "none";
return;
}
const tmp = document.createElement("div");
tmp.innerHTML = newHtml;
const fresh = tmp.firstElementChild;
if (fresh) {
el.innerHTML = fresh.innerHTML;
el.style.display = "";
}
});
}
function renderResults() {
ensureTtsAvailability(); // one-shot fetch of /api/tts/availability
const totalEntries = state.chunks.reduce((sum, c) => sum + c.entries.length, 0);
const lastChunk = state.chunks[state.chunks.length - 1];
const lastEntry = lastChunk ? lastChunk.entries[lastChunk.entries.length - 1] : null;
const totalDuration = lastEntry ? lastEntry.offset : 0;
const isPod = state.currentType === "podcast";
// Streaming indicator HTML — shown below the sections list while
// analyze windows are still landing. Goes away when the final
// result event arrives and clears state.streaming.
const stillAnalyzing = state.streaming;
const streamingIndicator = stillAnalyzing
? `<div class="streaming-indicator" style="padding:9px 14px; text-align:center; color:#94a3b8; font-size:12px !important; line-height:1.4; opacity:0.95; letter-spacing:0.02em; border-top:1px dashed #1e293b; margin-top:8px;">
<span class="streaming-dot" style="display:inline-block; width:6px; height:6px; border-radius:50%; background:#818cf8; margin-right:7px; animation:pulse 1.2s ease-in-out infinite;"></span>
Analyzing topics… ${state.streamWindowsDone || 0}${state.streamWindowsTotal ? "/" + state.streamWindowsTotal : ""} windows ready
</div>`
: "";
if (isPod) {
return `
<div style="padding: 0 4px;">
<div style="display:flex; align-items:center; gap:10px; padding:12px 0 8px;">
<span style="font-size:20px;">🎙</span>
<div style="flex:1; min-width:0;">
<div style="font-size:14px; font-weight:600; color:#e2e8f0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(state.videoTitle || "Podcast Episode")}</div>
<div style="font-size:11px; color:#64748b;">${stillAnalyzing ? "" : `${state.chunks.length} topics &middot; ${formatTime(totalDuration)} total`}</div>
</div>
<button class="expand-btn" onclick="toggleVideoMinimize()" title="${state.videoMinimized ? "Show player" : "Hide player"}" style="margin-right:4px;">
${state.videoMinimized ? "Show Player" : "Hide Player"}
</button>
${state.ttsAllowed && state.currentSessionId ? `<button class="expand-btn rp-listen-btn" onclick="openRecapPlayer()" title="Listen to the recap as audio">🎧 Listen</button>` : ""}
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
${!state.videoMinimized && state.url ? `
<audio id="podcast-audio" preload="none" style="width:100%; height:36px; margin-bottom:12px; border-radius:8px; outline:none;"
controls src="${escHtml(state.url)}"></audio>
` : ""}
<div style="border-top:1px solid #1e293b; padding-top:12px;"></div>
${renderSpeakersLegend()}
<div class="chunks-scroll" style="max-height: calc(100vh - ${state.videoMinimized ? "200" : "260"}px);">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
${state.chunks.length === 0 && stillAnalyzing ? `<div style="padding:24px 14px; text-align:center; color:#64748b; font-size:12px;">Topics will appear here as the model analyzes each section…</div>` : ""}
${streamingIndicator}
</div>
</div>
`;
}
return `
<div class="results-split">
<button class="landscape-back" onclick="screen.orientation.lock('portrait-primary').catch(()=>{})" title="Exit fullscreen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div class="results-left ${state.videoMinimized ? "minimized" : ""}" style="position:relative;">
${state.videoId ? `
<button class="minimize-toggle" onclick="toggleVideoMinimize()" title="Minimize video">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"></polyline><polyline points="20 10 14 10 14 4"></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</button>
<div class="video-mini-bar" onclick="toggleVideoMinimize()">
<span class="mini-btn" onclick="event.stopPropagation(); togglePlayPause()" title="Play/Pause">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"></polygon></svg>
</span>
<span class="mini-title">${escHtml(state.videoTitle || "Video")}</span>
<span class="mini-btn" title="Expand video">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</span>
</div>
<div class="video-embed">
<div id="yt-player"></div>
</div>
${state.videoTitle ? `<div class="video-title">${escHtml(state.videoTitle)}</div>` : ""}
<div class="video-meta">${stillAnalyzing ? "" : `${state.chunks.length} topics &middot; ${formatTime(totalDuration)} total`}</div>
${renderWatchOnYouTubeLink()}
` : ""}
</div>
<div class="results-right">
<div class="stats-bar">
<div class="stats">
${stillAnalyzing ? "" : `<span><strong>${state.chunks.length}</strong> topics</span>
<span><strong>${formatTime(totalDuration)}</strong> total</span>`}
</div>
<div style="display:flex; gap:6px;">
${state.ttsAllowed && state.currentSessionId ? `<button class="expand-btn rp-listen-btn" onclick="openRecapPlayer()" title="Listen to the recap as audio">🎧 Listen</button>` : ""}
<button class="expand-btn" onclick="event.stopPropagation(); showExportMenu(this, 'current')" title="Export — PDF, Markdown, or JSON">
Export ▾
</button>
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
</div>
${renderSpeakersLegend()}
<div class="chunks-scroll">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
${state.chunks.length === 0 && stillAnalyzing ? `<div style="padding:24px 14px; text-align:center; color:#64748b; font-size:12px;">Topics will appear here as the model analyzes each section…</div>` : ""}
${streamingIndicator}
</div>
</div>
</div>
`;
}
// Always-visible "Watch on YouTube" link — channels can disable
// third-party embedding, and when they do, YouTube's iframe just
// shows a "Video unavailable" error. This link gives users a
// one-click fallback regardless. Only renders when we actually
// have a YouTube videoId (not for podcasts).
function renderWatchOnYouTubeLink() {
if (!state.videoId || state.currentType === "podcast") return "";
const url = `https://www.youtube.com/watch?v=${encodeURIComponent(state.videoId)}`;
return `
<a href="${url}" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#94a3b8;text-decoration:none;margin-top:6px;"
title="Open in YouTube (some channels disable embedding)">
Watch on YouTube
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
`;
}
// ── Phase 1E speaker chip helpers ──────────────────────────────
// Map a global speaker ID ("Speaker_A", "Speaker_B", ..., or
// "Speaker_AA" for very chatty recordings) to one of 8 cycling
// CSS chip classes. Stable per label across the page so the same
// speaker always gets the same color in the legend AND each line.
function speakerChipClass(speakerLabel) {
if (!speakerLabel) return "";
// Speaker_Unknown is the pseudo-speaker the relay's post-
// cluster suppression pass assigns to brief utterances that
// didn't confidently match any anchor. Always rendered with
// the neutral slate "chip-h" color so it doesn't get mistaken
// for a real speaker.
if (speakerLabel === "Speaker_Unknown") return "chip-h";
const m = String(speakerLabel).match(/^Speaker_([A-Z]+)$/);
if (!m) return "chip-a";
const letters = m[1];
// Letter "A" = 0, "B" = 1, ... "Z" = 25, "AA" = 26, etc.
let n = 0;
for (const c of letters) {
n = n * 26 + (c.charCodeAt(0) - 64);
}
n -= 1; // back to 0-indexed
const cycle = "abcdefgh"[n % 8];
return "chip-" + cycle;
}
function speakerChipLetter(speakerLabel) {
if (speakerLabel === "Speaker_Unknown") return "?";
const m = String(speakerLabel || "").match(/^Speaker_([A-Z]+)$/);
return m ? m[1] : "?";
}
// Compute the chip display label. When the post-cluster polish
// identified a real name for this speaker, show its initials
// (e.g. "Matt Hill" → "MH", "Brandon Carpalis" → "BC", "Alice"
// → "A"). Otherwise fall back to the cluster letter ("A"/"B"/"C")
// so unidentified speakers stay legible. Tooltip on each chip
// still shows the full name for hover-disambiguation.
function speakerChipDisplay(speakerLabel) {
const names = state.speakerNames || {};
const name = typeof names[speakerLabel] === "string" && names[speakerLabel].trim()
? names[speakerLabel].trim()
: null;
if (!name) return speakerChipLetter(speakerLabel);
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return speakerChipLetter(speakerLabel);
if (parts.length === 1) return parts[0][0].toUpperCase();
// First initial + last initial. Skips middle names so "Matt
// Andrew Hill" → "MH" rather than "MAH" (keeps chip width
// tight against the 28px container).
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
// Inline chip rendered next to each transcript line. Empty
// string when no speaker is assigned (line falls outside any
// diarized segment OR diarization wasn't available for this
// session). Confidence < 0.5 marks the chip with a trailing "?"
// so the user knows the assignment is shaky.
function renderSpeakerChip(speaker, confidence, uncertain) {
if (!speaker) return "";
const cls = speakerChipClass(speaker);
// Use INITIALS when polish gave us a real name ("MH" for Matt
// Hill), fall back to cluster LETTER when unknown ("A").
const display = speakerChipDisplay(speaker);
// "?" suffix on the chip when either:
// (a) the diarize endpoint returned low per-segment confidence
// (< 0.5), or
// (b) the post-cluster suppression pass reassigned this
// segment's source cluster to an anchor as best-guess
// attribution (uncertain === true).
const showQuestion =
uncertain ||
(typeof confidence === "number" && confidence < 0.5);
const lowConf = showQuestion ? " low-conf" : "";
const names = state.speakerNames || {};
const inferredName = typeof names[speaker] === "string" && names[speaker].trim()
? names[speaker].trim()
: null;
let tooltipLabel;
if (speaker === "Speaker_Unknown") {
tooltipLabel = "Unknown speaker (brief utterance below the recognition threshold)";
} else {
tooltipLabel = inferredName
? `${inferredName} (${speaker})`
: speaker;
}
if (uncertain) tooltipLabel += " · best-guess attribution";
return `<span class="speaker-chip ${cls}${lowConf}" title="${escHtml(tooltipLabel)}${typeof confidence === "number" ? ` · conf ${(confidence * 100).toFixed(0)}%` : ""}">${escHtml(display)}</span>`;
}
// Legend block above the topic list. Returns "" when state.speakers
// is null/empty.
function renderSpeakersLegend() {
const speakers = state.speakers;
if (!speakers || typeof speakers !== "object") return "";
const entries = Object.entries(speakers);
if (entries.length === 0) return "";
// Sort by speaker label so Speaker_A is always first. The
// Speaker_Unknown pseudo-cluster (brief utterances that didn't
// match any anchor in the post-cluster suppression pass) always
// sorts to the end so the real speakers lead the legend.
entries.sort((a, b) => {
if (a[0] === "Speaker_Unknown") return 1;
if (b[0] === "Speaker_Unknown") return -1;
return a[0].localeCompare(b[0]);
});
// Phase 2 — prefer the inferred speaker name when available.
// state.speakerNames is a map { Speaker_A: "Matt Hill" | null,
// ... }. Null values mean polish couldn't confidently name
// that speaker; we fall back to "Speaker A".
const names = state.speakerNames || {};
const items = entries.map(([label, stats]) => {
const cls = speakerChipClass(label);
const letter = speakerChipLetter(label);
// Legend chip uses the same display as the per-line chips
// so the visual reference between legend and transcript is
// direct ("MH" in legend = "MH" on each line).
const chipDisplay = speakerChipDisplay(label);
const secs = Math.round(stats.total_speaking_seconds || 0);
const mins = Math.floor(secs / 60);
const rem = secs % 60;
const timeStr = mins > 0
? `${mins}:${String(rem).padStart(2, "0")}`
: `${rem}s`;
const inferredName = typeof names[label] === "string" && names[label].trim()
? names[label].trim()
: null;
let displayName;
if (label === "Speaker_Unknown") {
displayName = "Unknown";
} else {
displayName = inferredName || ("Speaker " + letter);
}
return `<span class="speakers-legend-item" title="${escHtml(label)}${inferredName ? ` — inferred name "${escHtml(inferredName)}"` : ""}">
<span class="speaker-chip ${cls}">${escHtml(chipDisplay)}</span>
<span>${escHtml(displayName)}</span>
<span class="legend-stats">· ${timeStr}</span>
</span>`;
}).join("");
return `<div class="speakers-legend">
<span class="speakers-legend-title">Speakers</span>
${items}
</div>`;
}
function renderChunk(chunk, index) {
const isExpanded = state.expandAll || state.expandedChunks.has(index);
const startSec = Math.floor(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTime = formatTime(endEntry.offset + (endEntry.duration || 0));
return `
<div class="chunk ${isExpanded ? "expanded" : ""}" id="chunk-${index}">
<div class="chunk-header">
<button class="chunk-play-btn" onclick="event.stopPropagation(); seekTo(${startSec}); highlightChunk(${index})" title="Play from ${formatTime(chunk.startTime)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"></polygon></svg>
</button>
<div class="chunk-info" onclick="toggleChunk(${index})" style="cursor:pointer">
<div class="chunk-title-row">
<span class="chunk-title">${escHtml(chunk.title)}</span>
<span class="chunk-time">${formatTime(chunk.startTime)} \u2013 ${endTime}</span>
</div>
<p class="chunk-summary">${escHtml(chunk.summary)}</p>
</div>
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn chunk-clip-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index})" title="Add topic to clips">📎</span>` : ""}
<div class="chunk-arrow" onclick="toggleChunk(${index})" style="cursor:pointer">\u25BE</div>
</div>
<div class="chunk-body">
<div class="chunk-body-inner">
${chunk.entries.map((entry, ei) => `
<button class="transcript-line" data-offset="${entry.offset}" onclick="seekTo(${Math.floor(entry.offset)})"
title="Play from ${formatTime(entry.offset)}">
<span class="ts-badge">\u25B6 ${formatTime(entry.offset)}</span>
${renderSpeakerChip(entry.speaker, entry.speaker_confidence, entry.speaker_uncertain)}
<span class="transcript-text">${escHtml(entry.text)}</span>
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index}, ${ei})" title="Add this line to clips">📎</span>` : ""}
</button>
`).join("")}
</div>
</div>
</div>
`;
}
function highlightChunk(index) {
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
const el = document.getElementById("chunk-" + index);
if (el) el.classList.add("now-playing");
}
// ── Audio-first "Listen" mode (Phase 3) ──────────────────────────
// A podcast-style player for the per-topic summary clips: plays them
// back-to-back, and "Listen to this part" drops into the real source
// audio/video at that topic's timestamp. Managed imperatively (its own
// DOM under <body>, outside the render()'d #app) so playback survives
// the app's re-render cycle. Backed by the Phase 2 /api/tts routes.
let ttsAvailabilityFetched = false;
async function ensureTtsAvailability() {
if (ttsAvailabilityFetched) return;
ttsAvailabilityFetched = true;
try {
const r = await fetch(`${API_BASE}/api/tts/availability`, { credentials: "same-origin" });
if (!r.ok) return;
const d = await r.json();
state.ttsAvailable = !!d.has_tts;
state.ttsAllowed = !!d.allowed;
state.ttsVoice = d.default_voice || null;
if (state.ttsAllowed) render(); // reveal the 🎧 Listen button
} catch {}
}
// SVG icons (crisper + more reliable than emoji glyphs, which can
// render identically for ▶/⏸ on some platforms).
const RP_ICON_PLAY = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="6,4 20,12 6,20"></polygon></svg>';
const RP_ICON_PAUSE = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"></rect><rect x="14" y="4" width="4" height="16" rx="1"></rect></svg>';
// Playback-speed steps the speed button cycles through. Values match
// YouTube's allowed setPlaybackRate values so the deep-dive source can
// use the same rate.
const RP_SPEEDS = [1, 1.25, 1.5, 1.75, 2];
const recapPlayer = {
sessionId: null, index: 0, total: 0,
ready: new Set(), // topics with a cached, playable clip
empty: new Set(), // topics with no summary text (no audio possible)
genInflight: new Map(), // index → in-flight generate promise (dedup)
waitingFor: null, keepPlaying: false, paused: false,
deepDive: false, deepEnd: Infinity,
speed: 1, watchdog: null, bgGen: false, retryTimer: null,
audio: null, uiTimer: null,
activeLine: null, userScrolledAt: 0,
};
function openRecapPlayer() {
if (!state.currentSessionId) { showToast("Save this recap first to listen.", "!"); return; }
pauseSource();
if (typeof stopTranscriptSync === "function") stopTranscriptSync();
Object.assign(recapPlayer, {
sessionId: state.currentSessionId, index: 0, total: state.chunks.length,
ready: new Set(), empty: new Set(), genInflight: new Map(), waitingFor: null,
keepPlaying: false, paused: false, deepDive: false, deepEnd: Infinity,
activeLine: null, userScrolledAt: 0,
});
buildPlayerOverlay();
rpSetTranscriptVisible(false); // transcript renders per-section on deep-dive
document.getElementById("recap-player-overlay").hidden = false;
const keep = document.getElementById("rp-keep");
if (keep) keep.checked = false;
setupMediaSession();
updatePlayerUI();
updateProgressDots();
updateSpeedBtn();
// Low-frequency UI sync so the play/pause icon ALWAYS reflects the
// real playback state, regardless of which media event fired (mobile
// autoplay/gesture quirks otherwise leave it stale).
if (recapPlayer.uiTimer) clearInterval(recapPlayer.uiTimer);
recapPlayer.uiTimer = setInterval(updatePlayPauseBtn, 400);
playTopic(0); // generates topic 0 on demand, then plays
startBackgroundGeneration(); // pre-generate the rest, in order
}
function closeRecapPlayer() {
recapPlayer.bgGen = false; // stop the background pre-generation loop
if (recapPlayer.retryTimer) { clearTimeout(recapPlayer.retryTimer); recapPlayer.retryTimer = null; }
stopWatchdog();
if (recapPlayer.uiTimer) { clearInterval(recapPlayer.uiTimer); recapPlayer.uiTimer = null; }
if (recapPlayer.audio) { try { recapPlayer.audio.pause(); } catch {} }
pauseSource();
const ov = document.getElementById("recap-player-overlay");
if (ov) ov.hidden = true;
if ("mediaSession" in navigator) { try { navigator.mediaSession.metadata = null; } catch {} }
}
function buildPlayerOverlay() {
if (document.getElementById("recap-player-overlay")) return;
const ov = document.createElement("div");
ov.id = "recap-player-overlay";
ov.className = "rp-overlay";
ov.hidden = true;
ov.innerHTML = `
<div class="rp-panel" role="dialog" aria-label="Audio recap player">
<div class="rp-head">
<div class="rp-source-title" id="rp-source-title"></div>
<button class="rp-close" onclick="closeRecapPlayer()" aria-label="Close">✕</button>
</div>
<div class="rp-now">
<div class="rp-topic-counter" id="rp-counter"></div>
<div class="rp-topic-title" id="rp-title"></div>
<div class="rp-status" id="rp-status"></div>
<div class="rp-summary" id="rp-summary"></div>
<div class="rp-transcript" id="rp-transcript" hidden></div>
</div>
<div class="rp-dots" id="rp-dots"></div>
<button class="rp-deeper" id="rp-deeper" onclick="onDeeperClick()">▶ Listen to this part</button>
<label class="rp-keep"><input type="checkbox" id="rp-keep" onchange="toggleKeepPlaying()"> Keep playing the original after this part</label>
<div class="rp-controls">
<button class="rp-ctrl rp-speed" id="rp-speed" onclick="cyclePlaybackSpeed()" aria-label="Playback speed" title="Playback speed">1&times;</button>
<button class="rp-ctrl" onclick="prevTopic()" aria-label="Previous topic">⏮</button>
<button class="rp-ctrl rp-play" id="rp-play" onclick="togglePlayerPlay()" aria-label="Play or pause">${RP_ICON_PLAY}</button>
<button class="rp-ctrl" onclick="nextTopic()" aria-label="Next topic">⏭</button>
</div>
</div>`;
ov.addEventListener("click", (e) => { if (e.target === ov) closeRecapPlayer(); });
document.body.appendChild(ov);
document.addEventListener("keydown", (e) => {
const o = document.getElementById("recap-player-overlay");
if (e.key === "Escape" && o && !o.hidden) closeRecapPlayer();
});
// Swipe left/right anywhere on the card → next / previous topic.
// Requires a mostly-horizontal drag so it doesn't fight the summary's
// vertical scroll or register button taps.
const panel = ov.querySelector(".rp-panel");
let touchX = null, touchY = null;
panel.addEventListener("touchstart", (e) => {
const t = e.changedTouches[0]; touchX = t.clientX; touchY = t.clientY;
}, { passive: true });
panel.addEventListener("touchend", (e) => {
if (touchX === null) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchX, dy = t.clientY - touchY;
touchX = touchY = null;
if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.5) {
dx < 0 ? nextTopic() : prevTopic();
}
}, { passive: true });
const a = document.createElement("audio");
a.id = "recap-audio";
a.preload = "auto";
a.addEventListener("ended", onRecapClipEnded);
["play", "playing", "pause", "waiting", "loadeddata"].forEach((ev) =>
a.addEventListener(ev, updatePlayPauseBtn)
);
a.addEventListener("error", onRecapAudioError);
document.body.appendChild(a);
recapPlayer.audio = a;
// While the user is scrolling the transcript, hold off auto-scroll so
// we don't yank them back to the playing line.
const tc = document.getElementById("rp-transcript");
if (tc) tc.addEventListener("scroll", () => { recapPlayer.userScrolledAt = Date.now(); }, { passive: true });
}
// Render the follow-along transcript for ONE section (the topic you're
// deep-diving) — not the whole recap — so you see just this part's words
// and can't scroll off into unrelated future sections. Each line seeks
// the source to its timestamp on tap (like the main app).
function rpRenderSection(ci) {
const c = document.getElementById("rp-transcript");
if (!c) return;
const ch = (state.chunks || [])[ci];
const entries = ch && Array.isArray(ch.entries) ? ch.entries : [];
c.innerHTML = entries.map((e) => {
const off = Math.floor(e.offset || 0);
return `<button class="rp-tline" data-offset="${off}" onclick="rpTranscriptSeek(${off}, ${ci})">` +
`<span class="rp-tts">${formatTime(e.offset || 0)}</span>` +
`<span>${escHtml(e.text || "")}</span></button>`;
}).join("");
recapPlayer.activeLine = null;
c.scrollTop = 0;
}
// Which section (chunk index) the source playhead at time `t` falls in.
function rpCurrentChunkForTime(t) {
const chunks = state.chunks || [];
let ci = 0;
for (let k = 0; k < chunks.length; k++) {
if (t >= (chunks[k].startTime || 0)) ci = k; else break;
}
return ci;
}
function rpSetTranscriptVisible(show) {
const t = document.getElementById("rp-transcript");
const s = document.getElementById("rp-summary");
if (t) t.hidden = !show;
if (s) s.style.display = show ? "none" : "";
}
// Highlight + auto-scroll the transcript line for source time `t`.
function rpSyncTranscript(t) {
const c = document.getElementById("rp-transcript");
if (!c || c.hidden) return;
// Re-scope to whichever section the playhead is in. With "keep playing"
// on, this advances the transcript (and the topic header) into the next
// section as the source rolls past the boundary.
const ci = rpCurrentChunkForTime(t);
if (ci !== recapPlayer.index) {
recapPlayer.index = ci;
const next = state.chunks[ci + 1];
recapPlayer.deepEnd = next ? Math.floor(next.startTime) : Infinity;
rpRenderSection(ci);
updatePlayerUI();
updateProgressDots();
updateMediaSessionMetadata();
}
const lines = c.querySelectorAll(".rp-tline");
let active = null;
for (const ln of lines) {
if (parseFloat(ln.dataset.offset) <= t) active = ln; else break;
}
if (active && active !== recapPlayer.activeLine) {
if (recapPlayer.activeLine) recapPlayer.activeLine.classList.remove("rp-tline-active");
active.classList.add("rp-tline-active");
recapPlayer.activeLine = active;
if (!recapPlayer.userScrolledAt || Date.now() - recapPlayer.userScrolledAt > 4000) {
active.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
}
// Tap a transcript line → seek the source there and re-anchor the topic.
function rpTranscriptSeek(offset, chunkIndex) {
lastSeekTarget = null;
recapPlayer.paused = false;
seekTo(offset);
if (typeof chunkIndex === "number" && state.chunks[chunkIndex]) {
if (chunkIndex !== recapPlayer.index) rpRenderSection(chunkIndex);
recapPlayer.index = chunkIndex;
const next = state.chunks[chunkIndex + 1];
recapPlayer.deepEnd = next ? Math.floor(next.startTime) : Infinity;
updatePlayerUI();
updateProgressDots();
updateMediaSessionMetadata();
}
applyPlaybackSpeed();
setTimeout(applyPlaybackSpeed, 300);
if (!recapPlayer.watchdog) startWatchdog();
}
// Deep-dive topic navigation: seek the source to an adjacent topic.
function rpSeekTopic(idx) {
idx = Math.max(0, Math.min(recapPlayer.total - 1, idx));
const ch = state.chunks[idx];
if (ch) rpTranscriptSeek(Math.floor(ch.startTime), idx);
}
// ── On-demand clip generation ──
// Each clip is generated when the player reaches it (and prefetched one
// ahead), and RETRIED until it succeeds — we never skip a topic. A
// background pass pre-generates the rest in order so they're usually
// ready before you arrive.
// Generate (or confirm cached) the clip for topic i. Deduped so the
// background pass and on-demand playback don't double-request. Resolves
// { ok } | { ok:false, empty:true } | { ok:false, error }.
function ensureClip(i) {
if (recapPlayer.ready.has(i)) return Promise.resolve({ ok: true });
if (recapPlayer.empty.has(i)) return Promise.resolve({ ok: false, empty: true });
if (recapPlayer.genInflight.has(i)) return recapPlayer.genInflight.get(i);
const p = fetch(
`${API_BASE}/api/tts/generate/${encodeURIComponent(recapPlayer.sessionId)}/${i}`,
{ method: "POST", credentials: "same-origin" }
)
.then(async (res) => {
let d = {};
try { d = await res.json(); } catch {}
if (res.ok && d.ok) { recapPlayer.ready.add(i); updateProgressDots(); return { ok: true }; }
if (d && d.empty) { recapPlayer.empty.add(i); updateProgressDots(); return { ok: false, empty: true }; }
return { ok: false, error: (d && d.error) || ("HTTP " + res.status) };
})
.catch((e) => ({ ok: false, error: e?.message || "network error" }))
.finally(() => { recapPlayer.genInflight.delete(i); });
recapPlayer.genInflight.set(i, p);
return p;
}
// Pre-generate every clip in order, in the background, so they're ready
// before playback reaches them. Resilient — any failure here is retried
// on demand when the user actually reaches that topic.
async function startBackgroundGeneration() {
recapPlayer.bgGen = true;
for (let i = 0; i < recapPlayer.total; i++) {
if (!recapPlayer.bgGen) return; // player closed
if (recapPlayer.ready.has(i) || recapPlayer.empty.has(i)) continue;
try { await ensureClip(i); } catch {}
}
}
function prefetch(i) {
if (i < 0 || i >= recapPlayer.total) return;
if (recapPlayer.ready.has(i) || recapPlayer.empty.has(i)) return;
ensureClip(i); // fire-and-forget
}
function playClip(i) {
const a = recapPlayer.audio;
a.src = `${API_BASE}/api/tts/audio/${encodeURIComponent(recapPlayer.sessionId)}/${i}`;
try { a.playbackRate = recapPlayer.speed; } catch {}
// Honor the paused intent across topic navigation: load the clip but
// only start it if the listener hasn't paused.
if (!recapPlayer.paused) a.play().catch(() => {});
updatePlayPauseBtn();
}
function playTopic(i) {
if (i < 0 || i >= recapPlayer.total) return;
if (recapPlayer.retryTimer) { clearTimeout(recapPlayer.retryTimer); recapPlayer.retryTimer = null; }
recapPlayer.index = i;
exitDeepDive(true); // leaving deep-dive → stop the real source
updatePlayerUI();
updateProgressDots();
updateMediaSessionMetadata();
if (recapPlayer.empty.has(i)) {
recapPlayer.waitingFor = null;
setNowPlayingStatus("This topic has no summary to read aloud.");
return; // don't auto-skip — the listener can move on with next / swipe
}
if (recapPlayer.ready.has(i)) {
recapPlayer.waitingFor = null;
setNowPlayingStatus("");
playClip(i);
prefetch(i + 1);
return;
}
// Not ready yet — generate it now, WAIT, keep the listener informed,
// and retry on failure. Never abandon the topic.
recapPlayer.waitingFor = i;
try { recapPlayer.audio.pause(); } catch {}
setNowPlayingStatus("Preparing the audio for this topic…");
updatePlayPauseBtn();
ensureClip(i).then((r) => {
if (recapPlayer.index !== i || recapPlayer.deepDive) return; // listener moved on
if (r.ok) {
recapPlayer.waitingFor = null;
setNowPlayingStatus("");
playClip(i);
prefetch(i + 1);
} else if (r.empty) {
recapPlayer.empty.add(i);
recapPlayer.waitingFor = null;
updateProgressDots();
setNowPlayingStatus("This topic has no summary to read aloud.");
} else {
// Transient failure — keep trying, keep the listener informed.
setNowPlayingStatus("This clip isn't ready yet — still generating…");
recapPlayer.retryTimer = setTimeout(() => {
if (recapPlayer.index === i && !recapPlayer.deepDive && !recapPlayer.ready.has(i)) playTopic(i);
}, 3000);
}
});
}
function onRecapClipEnded() {
if (recapPlayer.deepDive) return;
nextTopic();
}
function nextTopic() {
if (recapPlayer.deepDive) { rpSeekTopic(recapPlayer.index + 1); return; }
if (recapPlayer.index + 1 < recapPlayer.total) {
playTopic(recapPlayer.index + 1);
} else {
exitDeepDive(true);
try { recapPlayer.audio.pause(); } catch {}
setNowPlayingStatus("End of recap ✓");
updatePlayPauseBtn();
}
}
function prevTopic() {
if (recapPlayer.deepDive) { rpSeekTopic(recapPlayer.index - 1); return; }
playTopic(Math.max(0, recapPlayer.index - 1));
}
function togglePlayerPlay() {
if (recapPlayer.deepDive) {
if (sourceIsPlaying()) { recapPlayer.paused = true; pauseSource(); }
else { recapPlayer.paused = false; resumeSource(); }
setTimeout(updatePlayPauseBtn, 50);
return;
}
const a = recapPlayer.audio;
if (!a) return;
const i = recapPlayer.index;
if (!a.paused) { recapPlayer.paused = true; a.pause(); updatePlayPauseBtn(); return; }
// Paused → resume; if the current clip isn't loaded/ready yet, route
// through playTopic so it generates + waits rather than no-op.
recapPlayer.paused = false;
if (recapPlayer.ready.has(i) && a.src) a.play().catch(() => {});
else playTopic(i);
}
// ── "Go deeper": play the real source for this topic ──
function onDeeperClick() { recapPlayer.deepDive ? resumeRecap() : goDeeper(); }
function goDeeper() {
const chunk = state.chunks[recapPlayer.index];
if (!chunk) return;
recapPlayer.deepDive = true;
recapPlayer.paused = false;
recapPlayer.activeLine = null;
try { recapPlayer.audio.pause(); } catch {}
const next = state.chunks[recapPlayer.index + 1];
recapPlayer.deepEnd = next ? Math.floor(next.startTime) : Infinity;
lastSeekTarget = null; // force a fresh seek + play
seekTo(Math.floor(chunk.startTime));
highlightChunk(recapPlayer.index);
// Carry the chosen speed into the source. Delayed because seekTo may
// re-render to un-minimize the player before the element/iframe exists.
applyPlaybackSpeed();
setTimeout(applyPlaybackSpeed, 400);
rpRenderSection(recapPlayer.index); // this section's transcript only
rpSetTranscriptVisible(true); // show the follow-along transcript
startWatchdog();
setNowPlayingStatus("Playing the original — follow along below");
updatePlayerUI();
}
function resumeRecap() {
const i = recapPlayer.index;
exitDeepDive(true);
playTopic(i); // back to THIS topic's summary
}
function exitDeepDive(pauseTheSource) {
if (!recapPlayer.deepDive) return;
recapPlayer.deepDive = false;
stopWatchdog();
rpSetTranscriptVisible(false);
if (pauseTheSource) pauseSource();
updatePlayerUI();
}
function toggleKeepPlaying() {
const cb = document.getElementById("rp-keep");
recapPlayer.keepPlaying = !!(cb && cb.checked);
// The watchdog always runs while deep-diving (it also drives transcript
// sync); keepPlaying just gates the auto-return inside it.
if (recapPlayer.deepDive && !recapPlayer.watchdog) startWatchdog();
}
// While deep-diving: sync the follow-along transcript to the source's
// clock every tick, and — unless "keep playing" — pop back to the next
// summary when this topic's segment ends.
function startWatchdog() {
stopWatchdog();
recapPlayer.watchdog = setInterval(() => {
const t = sourceCurrentTime();
if (t == null) return;
// Auto-return at the section boundary FIRST (before the transcript
// re-scopes deepEnd to the next section) — unless "keep playing".
if (!recapPlayer.keepPlaying && recapPlayer.deepEnd !== Infinity && t >= recapPlayer.deepEnd) {
exitDeepDive(true);
nextTopic();
return;
}
rpSyncTranscript(t);
}, 400);
}
function stopWatchdog() {
if (recapPlayer.watchdog) { clearInterval(recapPlayer.watchdog); recapPlayer.watchdog = null; }
}
function sourceCurrentTime() {
if (state.currentType === "podcast") { const a = document.getElementById("podcast-audio"); return a ? a.currentTime : null; }
if (ytPlayer && ytPlayer.getCurrentTime) { try { return ytPlayer.getCurrentTime(); } catch { return null; } }
return null;
}
function sourceIsPlaying() {
if (state.currentType === "podcast") { const a = document.getElementById("podcast-audio"); return a ? !a.paused : false; }
if (ytPlayer && ytPlayer.getPlayerState) { try { return ytPlayer.getPlayerState() === YT.PlayerState.PLAYING; } catch { return false; } }
return false;
}
function pauseSource() {
try {
if (state.currentType === "podcast") { const a = document.getElementById("podcast-audio"); if (a) a.pause(); }
else if (ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo();
} catch {}
}
function resumeSource() {
try {
if (state.currentType === "podcast") { const a = document.getElementById("podcast-audio"); if (a) a.play().catch(() => {}); }
else if (ytPlayer && ytPlayer.playVideo) ytPlayer.playVideo();
} catch {}
}
function updatePlayerUI() {
const chunk = state.chunks[recapPlayer.index] || {};
const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
set("rp-counter", recapPlayer.total ? `Topic ${recapPlayer.index + 1} of ${recapPlayer.total}` : "");
set("rp-title", chunk.title || "");
set("rp-summary", chunk.summary || "");
set("rp-source-title", state.videoTitle || "");
const deeper = document.getElementById("rp-deeper");
if (deeper) {
if (recapPlayer.deepDive) { deeper.textContent = "← Back to the summary"; deeper.classList.add("rp-resume"); }
else { deeper.textContent = "▶ Listen to this part"; deeper.classList.remove("rp-resume"); }
}
updatePlayPauseBtn();
}
function setNowPlayingStatus(txt) {
const s = document.getElementById("rp-status");
if (s) s.textContent = txt || "";
}
function updatePlayPauseBtn() {
const btn = document.getElementById("rp-play");
const playing = recapPlayer.deepDive ? sourceIsPlaying() : !!(recapPlayer.audio && !recapPlayer.audio.paused);
if (btn) btn.innerHTML = playing ? RP_ICON_PAUSE : RP_ICON_PLAY;
if ("mediaSession" in navigator) { try { navigator.mediaSession.playbackState = playing ? "playing" : "paused"; } catch {} }
}
// ── Playback speed (segment clips AND the deep-dive source) ──
function cyclePlaybackSpeed() {
const i = RP_SPEEDS.indexOf(recapPlayer.speed);
recapPlayer.speed = RP_SPEEDS[(i + 1) % RP_SPEEDS.length];
updateSpeedBtn();
applyPlaybackSpeed();
}
function updateSpeedBtn() {
const btn = document.getElementById("rp-speed");
if (btn) btn.innerHTML = `${recapPlayer.speed}&times;`;
}
function applyPlaybackSpeed() {
const s = recapPlayer.speed;
if (recapPlayer.audio) { try { recapPlayer.audio.playbackRate = s; } catch {} }
const pa = document.getElementById("podcast-audio");
if (pa) { try { pa.playbackRate = s; } catch {} }
if (ytPlayer && ytPlayer.setPlaybackRate) { try { ytPlayer.setPlaybackRate(s); } catch {} }
}
// The current clip's <audio> failed to load (transient network error, or
// a cached file that went missing). Don't skip — drop it from the ready
// cache so we re-confirm/re-generate, and retry the same topic.
function onRecapAudioError() {
if (recapPlayer.deepDive) return;
const i = recapPlayer.index;
recapPlayer.ready.delete(i);
recapPlayer.genInflight.delete(i);
updateProgressDots();
setNowPlayingStatus("Trouble loading this clip — retrying…");
if (recapPlayer.retryTimer) clearTimeout(recapPlayer.retryTimer);
recapPlayer.retryTimer = setTimeout(() => {
if (recapPlayer.index === i && !recapPlayer.deepDive) playTopic(i);
}, 1200);
}
function updateProgressDots() {
const wrap = document.getElementById("rp-dots");
if (!wrap) return;
let html = "";
for (let i = 0; i < recapPlayer.total; i++) {
let cls = "rp-dot";
if (i === recapPlayer.index) cls += " active";
else if (recapPlayer.empty.has(i)) cls += " failed"; // no summary → dim
else if (recapPlayer.ready.has(i)) cls += " ready";
html += `<span class="${cls}" title="Topic ${i + 1}" style="cursor:pointer" onclick="playTopic(${i})"></span>`;
}
wrap.innerHTML = html;
}
function setupMediaSession() {
if (!("mediaSession" in navigator)) return;
try {
navigator.mediaSession.setActionHandler("play", () => togglePlayerPlay());
navigator.mediaSession.setActionHandler("pause", () => togglePlayerPlay());
navigator.mediaSession.setActionHandler("nexttrack", () => nextTopic());
navigator.mediaSession.setActionHandler("previoustrack", () => prevTopic());
} catch {}
}
function updateMediaSessionMetadata() {
if (!("mediaSession" in navigator) || typeof MediaMetadata === "undefined") return;
const chunk = state.chunks[recapPlayer.index] || {};
try {
navigator.mediaSession.metadata = new MediaMetadata({
title: chunk.title || "Recap",
artist: state.videoTitle || "Recap",
album: recapPlayer.total ? `Topic ${recapPlayer.index + 1} of ${recapPlayer.total}` : "",
});
} catch {}
}
// ── Actions ──────────────────────────────────────────────────────────────
function renderYtdlpStatus() {
if (state.ytdlpVersion === null && !state.ytdlpUpdateAvailable) {
return `<div class="ytdlp-status" style="color:#475569;background:rgba(255,255,255,0.02);border:1px solid #1e293b">Checking yt-dlp...</div>`;
}
if (state.ytdlpVersion === false) {
return `<div class="ytdlp-status ytdlp-err">
<span>yt-dlp not installed \u2014 required for audio download</span>
</div>`;
}
if (state.ytdlpUpdateAvailable) {
return `<div class="ytdlp-status ytdlp-warn">
<span>yt-dlp <strong>${escHtml(state.ytdlpVersion)}</strong> installed \u2014 <strong>${escHtml(state.ytdlpLatest)}</strong> available</span>
<button class="update-btn" onclick="updateYtdlp()" ${state.ytdlpUpdating ? "disabled" : ""}>
${state.ytdlpUpdating ? "Updating..." : "Update now"}
</button>
</div>`;
}
return `<div class="ytdlp-status ytdlp-ok">
<span>yt-dlp <strong>${escHtml(state.ytdlpVersion)}</strong> \u2014 up to date</span>
</div>`;
}
// Read-only display of the per-install UUID minted by the server.
// The install-id is intentionally NOT surfaced in the UI — showing
// it would advertise the "uninstall + reinstall to reset credits"
// workaround. It's still generated server-side on first boot and
// sent to the relay as X-Recap-Install-Id for credit accounting,
// just not displayed anywhere user-visible.
function renderCookieStatus() {
const uploadBtn = '<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;font-size:11px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;transition:all 0.15s;" ' +
'onmouseover="this.style.background=\'#334155\';this.style.color=\'#e2e8f0\'" onmouseout="this.style.background=\'#1e293b\';this.style.color=\'#94a3b8\'">' +
'Upload cookies.txt' +
'<input type="file" accept=".txt,.cookies" style="display:none" onchange="uploadCookieFile(this.files[0])">' +
'</label>';
const testBtn = '<button onclick="testCookies()" style="padding:5px 12px;font-size:11px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#334155\'" onmouseout="this.style.background=\'#1e293b\'">Test Cookies</button>';
const deleteBtn = '<button onclick="deleteCookieFile()" style="padding:5px 12px;font-size:11px;font-weight:600;background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.2);border-radius:6px;cursor:pointer;">Remove</button>';
let html = "";
// ── PO Token Plugin status (most important for bot detection) ──
{
html += '<label class="field-label">YouTube Bot Detection</label>' +
'<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:6px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="font-size:12px;color:#e2e8f0;">Smart retry: <strong style="color:#4ade80;">enabled</strong></span>' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.5;">' +
'If YouTube blocks a download ("Sign in to confirm you\'re not a bot"), the app ' +
'will automatically wait and retry up to 3 times with increasing delays (30s, 60s, 2min).<br><br>' +
'<span style="color:#64748b;">Common causes of blocks: VPN/proxy use, too many downloads in a short period, ' +
'or YouTube\'s general anti-bot measures. Turning off VPN and waiting usually resolves it.</span></span>' +
'</div>';
}
// ── Cookie status ──
let cookieHtml = "";
if (state.cookieMethod === "none") {
cookieHtml = '<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="color:#94a3b8;">No YouTube cookies configured</span>' +
'<span style="font-size:11px;color:#64748b;line-height:1.5;">' +
'Cookies authenticate your server with YouTube to avoid bot detection blocks and access restricted videos.<br><br>' +
'<strong style="color:#94a3b8;">To set up:</strong><br>' +
'1. Install the "Get cookies.txt LOCALLY" browser extension on your laptop<br>' +
'2. Go to youtube.com and make sure you\'re signed in<br>' +
'3. Click the extension icon and export cookies for youtube.com<br>' +
'4. Upload the downloaded cookies.txt file here<br><br>' +
'<span style="color:#fbbf24;">Note:</span> Cookies expire after ~2 weeks. You\'ll need to re-upload periodically.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + '</div>' +
'</div>';
} else if (state.cookieMethod === "cookies.txt") {
const age = state.cookieFileAgeDays;
const ageStr = (age !== null && age !== undefined) ? (age + " day" + (age !== 1 ? "s" : "") + " old") : "";
if (state.cookieFileExpiring) {
cookieHtml = '<div class="ytdlp-status ytdlp-warn" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>cookies.txt is ' + ageStr + ' \u2014 likely expiring soon</span>' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.4;">Cookies typically expire after ~14 days. Upload a fresh cookies.txt to keep Premium/ad-free downloads.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + ' ' + deleteBtn + '</div>' +
'</div>';
} else {
cookieHtml = '<div class="ytdlp-status ytdlp-ok" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>YouTube auth: <strong>cookies.txt</strong>' + (ageStr ? ' (' + ageStr + ')' : '') + '</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + ' ' + deleteBtn + '</div>' +
'</div>';
}
} else {
// Browser cookies
cookieHtml = '<div class="ytdlp-status ytdlp-ok" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>YouTube auth: <strong>' + escHtml(state.cookieMethod) + '</strong> browser cookies</span>' +
'<span style="font-size:11px;color:#94a3b8;">Stay signed into YouTube in ' + escHtml(state.cookieMethod) + '. For remote servers, upload a cookies.txt instead.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + '</div>' +
'</div>';
}
html += '<label class="field-label" style="margin-top:12px;">YouTube Cookies</label>' + cookieHtml;
return html + '<div id="cookie-test-result"></div>';
}
async function uploadCookieFile(file) {
if (!file) return;
try {
const text = await file.text();
const res = await fetch(API_BASE + "/api/cookies/upload", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: text,
});
const data = await res.json();
if (data.error) {
showCookieResult(data.error, true);
return;
}
// Refresh cookie status
const statusRes = await fetch(API_BASE + "/api/cookies/status");
const status = await statusRes.json();
state.cookieMethod = status.method || "cookies.txt";
state.cookieFileAgeDays = status.fileAgeDays;
state.cookieFileExpiring = status.fileExpiring || false;
showCookieResult("Cookies uploaded successfully!", false);
render();
} catch (e) {
showCookieResult("Upload failed: " + e.message, true);
}
}
async function deleteCookieFile() {
if (!confirm("Remove the cookies.txt file? YouTube may block downloads without authentication.")) return;
try {
await fetch(API_BASE + "/api/cookies/delete", { method: "POST" });
state.cookieMethod = "none";
state.cookieFileAgeDays = null;
state.cookieFileExpiring = false;
render();
} catch (e) {
showCookieResult("Delete failed: " + e.message, true);
}
}
async function testCookies() {
showCookieResult("Testing cookies...", false);
try {
const res = await fetch(API_BASE + "/api/cookies/test", { method: "POST" });
const data = await res.json();
showCookieResult(data.ok ? data.message : data.error, !data.ok);
} catch (e) {
showCookieResult("Test failed: " + e.message, true);
}
}
function showCookieResult(msg, isError) {
const el = document.getElementById("cookie-test-result");
if (!el) return;
el.innerHTML = '<div style="margin-top:6px;padding:8px 12px;border-radius:6px;font-size:12px;' +
(isError ? 'background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.2);' :
'background:rgba(34,197,94,0.1);color:#4ade80;border:1px solid rgba(34,197,94,0.2);') +
'">' + escHtml(msg) + '</div>';
// Auto-clear after 8 seconds
setTimeout(() => { if (el) el.innerHTML = ""; }, 8000);
}
async function updateYtdlp() {
state.ytdlpUpdating = true;
render();
try {
const res = await fetch(API_BASE + "/api/update-ytdlp", { method: "POST" });
const data = await res.json();
state.ytdlpVersion = data.version || state.ytdlpVersion;
state.ytdlpLatest = data.latestVersion;
state.ytdlpUpdateAvailable = data.updateAvailable || false;
} catch (e) {
state.error = "Failed to update yt-dlp: " + e.message;
} finally {
state.ytdlpUpdating = false;
render();
}
}
function renderLogDrawer() {
return `
<div class="log-drawer-overlay" onclick="toggleLog()"></div>
<div class="log-drawer">
<div class="log-drawer-header">
<h2>Activity Log</h2>
<div style="display:flex; align-items:center; gap:8px;">
${state.logs.length > 0 ? `
<button onclick="clearLogHistory()" title="Clear all activity-log entries"
style="background:transparent; border:1px solid #334155; color:#94a3b8; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:11px; font-weight:600;"
onmouseover="this.style.borderColor='#dc2626'; this.style.color='#f87171';"
onmouseout="this.style.borderColor='#334155'; this.style.color='#94a3b8';">
Clear
</button>` : ""}
<button class="close-btn" onclick="toggleLog()">&times;</button>
</div>
</div>
<div class="log-drawer-body" id="log-body">
${state.logs.length === 0
? `<div class="log-empty">No activity yet. Submit a URL to see the processing log.</div>`
: renderLogEntries(state.logs)}
</div>
</div>
`;
}
// Render the body of the activity log. Each separator (── title ──)
// anchors a collapsible group: entries after the separator are
// hidden until the next separator when the group is collapsed.
// Group identity is the separator's index in state.logs — stable
// across renders, simple to toggle.
function renderLogEntries(logs) {
let out = "";
let activeGroupIdx = -1; // index of current separator; -1 means "before any separator"
let activeCollapsed = false;
let activeCount = 0; // entries seen in the current group (for the chip)
for (let i = 0; i < logs.length; i++) {
const l = logs[i];
if (l.separator) {
// Close the previous group's wrapper if we had one
if (activeGroupIdx >= 0) out += `</div>`;
activeGroupIdx = i;
activeCollapsed = state.collapsedLogGroups.has(i);
activeCount = 0;
const chevron = activeCollapsed ? "▸" : "▾";
out += `
<div class="log-entry log-group-header" onclick="toggleLogGroup(${i})"
style="cursor:pointer; user-select:none; border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px; display:flex; align-items:center; gap:6px;">
<span style="display:inline-block; width:10px; transform:translateY(-1px);">${chevron}</span>
<span class="log-msg" style="flex:1;">${escHtml(l.message)}</span>
<span class="log-group-count" data-group="${i}" style="font-size:10px; color:#64748b; font-weight:400;"></span>
</div>
<div class="log-group-body" data-group="${i}" style="${activeCollapsed ? "display:none;" : ""}">`;
continue;
}
// Non-separator entry. If we're not inside any group yet (logs
// without a leading separator), open an implicit group wrapper
// so future collapse logic doesn't break.
if (activeGroupIdx === -1) {
out += `<div class="log-group-body" data-group="-1">`;
activeGroupIdx = -1; // sentinel, won't be matched by toggle
activeCollapsed = false;
}
activeCount++;
out += `
<div class="log-entry ${l.error ? "error" : ""} ${l.message.includes("Pipeline finished") ? "done" : ""} ${l.message.includes("cost:") || l.message.includes("tokens:") ? "cost" : ""}">
<span class="log-time">${l.elapsed}s</span>
<span class="log-msg">${escHtml(l.message)}${l.detail ? ` <span class="log-detail">(${escHtml(l.detail)})</span>` : ""}</span>
</div>`;
}
if (activeGroupIdx !== -1 || logs.some((l) => !l.separator)) {
// Close the final group wrapper (either real or implicit)
out += `</div>`;
}
return out;
}
function toggleLogGroup(idx) {
const wasCollapsed = state.collapsedLogGroups.has(idx);
if (wasCollapsed) {
state.collapsedLogGroups.delete(idx);
} else {
state.collapsedLogGroups.add(idx);
}
// Surgical DOM update: toggle the body display + chevron icon
// without calling render(). A full render() rebuilds the entire
// app DOM — wipes the YouTube iframe, resets scroll position in
// the activity log + results view. Painful UX for what should
// be a 1-element flip.
const body = document.querySelector(`.log-group-body[data-group="${idx}"]`);
if (body) {
body.style.display = wasCollapsed ? "" : "none";
}
// Find the matching header by attribute selector; flip the
// chevron text. The first <span> inside the header is the
// chevron (per renderLog markup).
const headers = document.querySelectorAll(".log-group-header");
for (const h of headers) {
if (h.getAttribute("onclick") === `toggleLogGroup(${idx})`) {
const chev = h.querySelector("span");
if (chev) chev.textContent = wasCollapsed ? "▾" : "▸";
break;
}
}
}
function renderLog() {
const el = document.getElementById("log-body");
if (!el || !state.logOpen) return;
const last = state.logs[state.logs.length - 1];
if (!last) return;
// Separators introduce a brand-new collapsible group + body
// wrapper; the structural shape is non-trivial to splice in
// surgically, so just re-render the drawer body for separators
// and keep the fast append path for the common case (plain log
// entries).
if (last.separator) {
el.innerHTML = renderLogEntries(state.logs);
el.scrollTop = el.scrollHeight;
return;
}
const entry = document.createElement("div");
entry.className = "log-entry" + (last.error ? " error" : "") + (last.message.includes("Pipeline finished") ? " done" : "") + (last.message.includes("cost:") || last.message.includes("tokens:") ? " cost" : "");
entry.innerHTML = `<span class="log-time">${last.elapsed}s</span><span class="log-msg">${escHtml(last.message)}${last.detail ? ` <span class="log-detail">(${escHtml(last.detail)})</span>` : ""}</span>`;
// Append into the most recent group's body if one exists; else
// append at the drawer root (logs without a leading separator).
const groups = el.querySelectorAll(".log-group-body");
const target = groups.length ? groups[groups.length - 1] : el;
target.appendChild(entry);
el.scrollTop = el.scrollHeight;
}
function toggleQueueCollapse() {
state.queueCollapsed = !state.queueCollapsed;
render();
}
function renderQueue() {
const processingItem = state.queue.find(q => q.status === "processing");
const pendingCount = state.queue.filter(q => q.status === "pending_approval").length;
const queuedCount = state.queue.filter(q => q.status === "queued").length;
const summaryParts = [];
if (processingItem) summaryParts.push("processing 1");
if (queuedCount) summaryParts.push(`${queuedCount} queued`);
if (pendingCount) summaryParts.push(`${pendingCount} pending`);
return `
<div class="queue-section">
<div class="queue-label" onclick="toggleQueueCollapse()" style="cursor: pointer; display: flex; align-items: center; gap: 6px; user-select: none;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="transition: transform 0.2s; transform: rotate(${state.queueCollapsed ? '-90deg' : '0'});">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
Queue (${state.queue.length})
${state.queueCollapsed && summaryParts.length ? `<span style="font-size: 10px; color: #818cf8; font-weight: 400; text-transform: none; letter-spacing: 0;">— ${summaryParts.join(", ")}</span>` : ""}
${!state.queueCollapsed && pendingCount > 1 ? `<button class="queue-approve-all" onclick="event.stopPropagation(); approveAllQueue()">Approve All (${pendingCount})</button>` : ""}
</div>
${state.queueCollapsed ? "" : state.queue.map((q, i) => {
if (q.status === "pending_approval") {
const dateStr = q.uploadDate ? (() => {
const d = q.uploadDate;
const dt = new Date(d.slice(0,4) + "-" + d.slice(4,6) + "-" + d.slice(6,8));
return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
})() : "";
return `
<div class="queue-item pending-approval">
<span class="queue-pos" style="background: #1e293b; color: ${q.type === 'podcast' ? '#34d399' : '#818cf8'}; font-size: 10px;">${q.type === 'podcast' ? '🎙' : 'NEW'}</span>
<span class="queue-title" title="${escHtml(q.url)}">${escHtml(q.title || q.url)}</span>
${dateStr ? `<span style="font-size:10px; color:#64748b; flex-shrink:0; white-space:nowrap;">${dateStr}</span>` : ""}
${q.fromSubscription ? `<span class="queue-from">${escHtml(q.fromSubscription)}</span>` : ""}
<span class="queue-actions">
<button class="queue-approve" onclick="approveQueueItem('${q.id}')" title="Approve">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button class="queue-reject" onclick="rejectQueueItem('${q.id}')" title="Reject (skip)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</span>
</div>`;
}
return `
<div class="queue-item ${q.status === "processing" ? "processing" : ""}">
<span class="queue-pos">${q.status === "processing" ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></circle></svg>` : i + 1}</span>
<span class="${q.title ? 'queue-title' : 'queue-url'}" title="${escHtml(q.url)}">${escHtml(q.title || q.url)}</span>
${q.fromSubscription ? `<span class="queue-from">${escHtml(q.fromSubscription)}</span>` : ""}
<span class="queue-status">${q.status === "processing" ? "Processing..." : "Queued"}</span>
<button class="queue-remove" onclick="removeFromQueue('${q.id}')" title="Remove">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>`;
}).join("")}
</div>
`;
}
function toggleLog() { state.logOpen = !state.logOpen; render(); }
// "What links can I paste?" popover toggle. The event arg lets us
// stopPropagation so the click-outside handler doesn't immediately
// close the popover we just opened.
function toggleFormatsInfo(e) {
if (e) e.stopPropagation();
state.formatsInfoOpen = !state.formatsInfoOpen;
render();
}
function closeFormatsInfo() {
if (state.formatsInfoOpen) {
state.formatsInfoOpen = false;
render();
}
}
// Global click handler: when the popover is open, any click that
// isn't on the info button or inside the popover closes it. Mounted
// once at boot; cheap (no-op when popover is closed).
document.addEventListener("click", (e) => {
if (!state.formatsInfoOpen) return;
const card = e.target.closest(".formats-info-card");
const btn = e.target.closest(".info-btn");
if (card || btn) return;
closeFormatsInfo();
});
// Brand-styled "what can I paste?" card. Rendered as a popover
// anchored under the info button on the input bar. Content is
// intentionally compact — a list of supported source formats with
// one line of value-prop, plus a teaser for the paid subscriptions
// feature so trial / free users see a reason to upgrade later.
function renderFormatsInfoCard() {
// Paid users see the "Subscribe to feeds" item as an active
// capability. Free / trial users see it as a soft upsell line.
const hasSubs = hasEntitlement("subscriptions");
const subsItem = hasSubs
? `<li><strong>Subscribe to channels and podcasts</strong> Recaps auto-summarizes new episodes as they're published. Find the Subscribe button by pasting a channel or feed URL.</li>`
: `<li style="opacity:0.75;"><strong>Subscribe to channels and podcasts</strong> <span style="color:#a5b4fc;font-size:11px;font-weight:600;">&middot; Pro</span> — auto-summarize new episodes as they're published.</li>`;
// Free-credits hint shown ONLY to anonymous visitors who haven't
// yet minted a trial cookie. After their first summarize the
// trial counter takes over and this line goes away. Hidden for
// signed-in users (who see their tenant_credits balance instead)
// and for trial holders who've already started using credits.
const showAnonCreditsHint =
isMulti() &&
state.account?.state === "anonymous" &&
(state.account?.available_trial_credits || 0) > 0;
const anonCreditsLine = showAnonCreditsHint
? `<div style="margin-bottom:12px;padding:10px 12px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.30);border-radius:8px;font-size:12px;color:#a5b4fc;">
<strong style="color:#c7d2fe;">${state.account.available_trial_credits} free credit${state.account.available_trial_credits === 1 ? "" : "s"}</strong> — paste a link to claim ${state.account.available_trial_credits === 1 ? "it" : "them"}. Sign up after for more.
</div>`
: "";
return `
<div class="formats-info-card" onclick="event.stopPropagation()">
<div class="formats-info-card-header">
<strong>What can I recap?</strong>
<button class="formats-info-close" type="button" onclick="closeFormatsInfo()" aria-label="Close">&times;</button>
</div>
${anonCreditsLine}
<ul class="formats-info-list">
<li><strong>YouTube videos</strong> — paste any youtube.com or youtu.be link.</li>
<li><strong>Podcast RSS feeds</strong> — paste a feed URL and Recaps will summarize the latest episode.</li>
<li><strong>Apple Podcasts</strong> — share an episode link from the Podcasts app. Recaps resolves it to the underlying audio.</li>
<li><strong>Spotify podcasts</strong> — share an episode link from Spotify. Recaps resolves it the same way.</li>
<li><strong>Fountain</strong> — paste a fountain.fm episode link; we pull the audio directly from Fountain's CDN.</li>
${subsItem}
</ul>
</div>
`;
}
function toggleMobileMenu(e) {
e && e.stopPropagation();
state.mobileMenuOpen = !state.mobileMenuOpen;
render();
}
function closeMobileMenu() {
if (state.mobileMenuOpen) { state.mobileMenuOpen = false; render(); }
}
function toggleSettings() {
state.settingsOpen = !state.settingsOpen;
if (state.settingsOpen && !state.subsLoaded && canUseSubscriptions()) loadSubscriptions();
// Lazy-load multi-tenant admin/account data when the modal opens.
// Admins get the tenant list + activity feed; tenants get their
// own sessions. Idempotent (loaders check state before fetching),
// but we deliberately re-fetch on every open so a returning
// operator sees fresh data.
if (state.settingsOpen && isMulti()) {
if (isAdmin()) {
loadAdminTenants();
loadAdminActivity(state.ops.activityHours || 24);
} else if (state.account?.user) {
loadMySessions();
loadMyDigest();
}
}
render();
}
// ── History ───────────────────────────────────────────────────────────
let historyAnimateIn = false;
async function toggleHistory() {
state.historyOpen = !state.historyOpen;
historyAnimateIn = state.historyOpen; // only animate when opening
if (state.historyOpen && !state.historyLoaded) {
await loadHistory();
}
render();
// Clear the flag after render so re-renders don't re-animate
setTimeout(() => { historyAnimateIn = false; }, 300);
}
async function loadHistory() {
try {
const res = await fetch(`${API_BASE}/api/history`);
const data = await res.json();
state.historySessions = data.sessions || {};
state.historyMeta = data.meta || { folders: [], uncategorized: [] };
// Hydrate collapsed-folder UI state from persisted server meta
state.collapsedFolders = new Set(
(state.historyMeta.folders || [])
.filter(f => f.collapsed)
.map(f => f.id)
);
state.historyLoaded = true;
} catch {
state.historySessions = {};
state.historyMeta = { folders: [], uncategorized: [] };
}
}
async function loadSession(id) {
try {
const res = await fetch(`${API_BASE}/api/history/${id}`);
const data = await res.json();
// Reading a saved episode — stop following any in-flight stream so
// its analysis windows don't override this view (the job keeps
// running in the background and lands in the library when done).
state.followStream = false;
state.currentSessionId = id;
state.videoId = data.videoId;
state.videoTitle = data.title || "";
state.url = data.url || "";
state.chunks = data.chunks || [];
// Speaker legend persisted alongside chunks in v0.2.121+
// (Phase 1E). Older sessions don't have this field — leaves
// null, frontend renders without speaker chips.
state.speakers = data.speakers || null;
// Phase 2 — saved-session speaker names from the relay's
// post-cluster polish pass. Older saved sessions don't have
// this field — null is fine, legend falls back to letters.
state.speakerNames = data.speakerNames || null;
state.logs = data.logs || [];
state.currentType = data.type || "youtube";
state.expandedChunks = new Set();
state.expandAll = false;
state.loading = false;
state.streaming = false; // viewing a completed saved episode
state.error = null;
state.videoMinimized = false;
// On mobile, close sidebar after selection; on desktop, keep it open
if (window.innerWidth <= 900) state.historyOpen = false;
ytCurrentVideoId = null; // force fresh player for loaded session
render();
} catch (e) {
state.error = "Failed to load session: " + e.message;
render();
}
}
function startEditSessionTitle(id) {
state.editingSessionTitle = id;
render();
setTimeout(() => {
const el = document.getElementById("session-title-edit-" + id);
if (el) { el.focus(); el.select(); }
}, 50);
}
async function renameSession(id, newTitle) {
state.editingSessionTitle = null;
const session = state.historySessions[id];
if (session && newTitle.trim()) {
session.title = newTitle.trim();
}
render();
try {
await fetch(`${API_BASE}/api/history/${id}/title`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle.trim() }),
});
} catch {}
}
async function deleteSession(id, ev) {
ev.stopPropagation();
try {
// Animate the item out
const el = ev.target.closest(".history-item");
if (el) {
el.classList.add("removing");
}
// Fire the delete request
fetch(`${API_BASE}/api/history/${id}`, { method: "DELETE" }).catch(() => {});
// Wait for animation, then update state
await new Promise(r => setTimeout(r, 300));
delete state.historySessions[id];
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== id);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== id);
// Surgically update just the sidebar instead of full re-render
const sidebar = document.querySelector(".history-sidebar");
if (sidebar && state.historyOpen) {
// Capture current scroll so the user stays where they were
const prevList = sidebar.querySelector(".history-list");
const prevScroll = prevList ? prevList.scrollTop : 0;
const sidebarHtml = renderHistorySidebar();
const temp = document.createElement("div");
temp.innerHTML = sidebarHtml;
// Replace sidebar content (keep the element to avoid re-animation)
const newSidebar = temp.querySelector(".history-sidebar");
if (newSidebar) sidebar.innerHTML = newSidebar.innerHTML;
// Restore scroll position on the freshly created list
const newList = sidebar.querySelector(".history-list");
if (newList && prevScroll > 0) newList.scrollTop = prevScroll;
} else {
render();
}
} catch {}
}
async function createFolder() {
try {
const res = await fetch(`${API_BASE}/api/history/folders`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Folder" }),
});
const folder = await res.json();
state.historyMeta.folders.push(folder);
state.editingFolder = folder.id;
render();
// Focus the input
setTimeout(() => { const el = document.getElementById("folder-edit-" + folder.id); if (el) { el.focus(); el.select(); } }, 50);
} catch {}
}
async function renameFolder(id, name) {
state.editingFolder = null;
const folder = state.historyMeta.folders.find(f => f.id === id);
if (folder) folder.name = name || folder.name;
render();
try {
await fetch(`${API_BASE}/api/history/folders/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
} catch {}
}
async function deleteFolder(id, ev) {
ev.stopPropagation();
try {
await fetch(`${API_BASE}/api/history/folders/${id}`, { method: "DELETE" });
const idx = state.historyMeta.folders.findIndex(f => f.id === id);
if (idx !== -1) {
const [folder] = state.historyMeta.folders.splice(idx, 1);
state.historyMeta.uncategorized = [...folder.items, ...state.historyMeta.uncategorized];
}
render();
} catch {}
}
function toggleFolder(id) {
const nowCollapsed = !state.collapsedFolders.has(id);
if (nowCollapsed) state.collapsedFolders.add(id);
else state.collapsedFolders.delete(id);
// Mirror into local meta so re-renders stay consistent without a refetch
const folder = state.historyMeta.folders.find(f => f.id === id);
if (folder) folder.collapsed = nowCollapsed;
render();
// Persist to server (fire-and-forget; UI already updated optimistically)
fetch(`${API_BASE}/api/history/folders/${id}/collapsed`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collapsed: nowCollapsed }),
}).catch(() => {});
}
// Drag & drop with insertion indicator
let dragDropTarget = null; // { sessionId, position: "above"|"below", folderId }
function onDragStart(ev, sessionId) {
state.draggingId = sessionId;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", sessionId);
// Use timeout so the dragging class applies after the drag image is captured
setTimeout(() => ev.target.classList.add("dragging"), 0);
}
function onDragEnd(ev) {
state.draggingId = null;
dragDropTarget = null;
document.querySelectorAll(".dragging, .drop-above, .drop-below, .drag-over").forEach(
el => el.classList.remove("dragging", "drop-above", "drop-below", "drag-over")
);
}
function onItemDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const item = ev.currentTarget;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position = ev.clientY < midY ? "above" : "below";
// Clear all indicators
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
// Set indicator on this item
item.classList.add(position === "above" ? "drop-above" : "drop-below");
// Track target for drop
const targetId = item.dataset.sessionId;
const folderId = item.dataset.folderId || null;
dragDropTarget = { targetId, position, folderId };
}
function onItemDragLeave(ev) {
const item = ev.currentTarget;
// Only remove if actually leaving (not entering a child)
if (!item.contains(ev.relatedTarget)) {
item.classList.remove("drop-above", "drop-below");
}
}
async function onItemDrop(ev) {
ev.preventDefault();
ev.stopPropagation();
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
const sessionId = state.draggingId;
if (!sessionId || !dragDropTarget) return;
if (sessionId === dragDropTarget.targetId) return; // Dropped on self
const { targetId, position, folderId } = dragDropTarget;
// Determine the target list and insertion index
let targetList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
targetList = folder ? folder.items : null;
} else {
targetList = state.historyMeta.uncategorized;
}
if (!targetList) return;
// Find where the target is and compute insertion index
const targetIdx = targetList.indexOf(targetId);
if (targetIdx === -1) return;
const insertIdx = position === "below" ? targetIdx + 1 : targetIdx;
// Remove from all lists first
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
// Re-resolve target list after removal (indices may have shifted)
let finalList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
finalList = folder ? folder.items : null;
} else {
finalList = state.historyMeta.uncategorized;
}
if (!finalList) return;
// Compute the final index (target may have shifted if dragged item was before it)
const finalTargetIdx = finalList.indexOf(targetId);
const finalInsertIdx = position === "below" ? finalTargetIdx + 1 : finalTargetIdx;
finalList.splice(finalInsertIdx, 0, sessionId);
state.draggingId = null;
dragDropTarget = null;
render();
// Persist to server
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId, index: finalInsertIdx }),
});
} catch {}
}
// Folder header drop (move into a folder, appended at end)
async function onDropToFolder(ev, folderId) {
ev.preventDefault();
ev.currentTarget.classList.remove("drag-over");
const sessionId = state.draggingId;
if (!sessionId) return;
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
if (folder) folder.items.push(sessionId);
} else {
state.historyMeta.uncategorized.push(sessionId);
}
state.draggingId = null;
dragDropTarget = null;
render();
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId }),
});
} catch {}
}
function onListDragOver(ev) { ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; }
async function onDropToUncategorized(ev) { await onDropToFolder(ev, null); }
function formatUploadDate(yyyymmdd) {
// yt-dlp returns YYYYMMDD, e.g. "20260207"
if (!yyyymmdd || yyyymmdd.length !== 8) return "";
const y = yyyymmdd.slice(0, 4);
const m = parseInt(yyyymmdd.slice(4, 6), 10);
const d = parseInt(yyyymmdd.slice(6, 8), 10);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[m - 1]} ${d}, ${y}`;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return mins + "m ago";
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + "h ago";
const days = Math.floor(hrs / 24);
if (days < 7) return days + "d ago";
return new Date(dateStr).toLocaleDateString();
}
function renderHistoryItem(id, folderId) {
const h = state.historySessions[id];
if (!h) return "";
const isEditing = state.editingSessionTitle === h.id;
const folderAttr = folderId ? `data-folder-id="${folderId}"` : "";
return `
<div class="history-item ${state.videoId === h.videoId ? "active" : ""}"
draggable="${isEditing ? "false" : "true"}"
data-session-id="${h.id}" ${folderAttr}
ondragstart="onDragStart(event, '${h.id}')"
ondragend="onDragEnd(event)"
ondragover="onItemDragOver(event)"
ondragleave="onItemDragLeave(event)"
ondrop="onItemDrop(event)"
onclick="${isEditing ? "" : "loadSession('" + h.id + "')"}">
<div class="history-thumb">
${h.type === "podcast"
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#1e293b;border-radius:6px;font-size:20px;">🎙</div>`
: `<img src="https://img.youtube.com/vi/${h.videoId}/default.jpg" alt="" loading="lazy" />`}
</div>
<div class="history-info">
${isEditing
? `<input class="history-title-input" id="session-title-edit-${h.id}"
value="${escHtml(h.title)}"
onclick="event.stopPropagation()"
onblur="renameSession('${h.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingSessionTitle=null;render()}" />`
: `<div class="history-title" title="${escHtml(h.title)} (double-click to rename)"
ondblclick="event.stopPropagation(); startEditSessionTitle('${h.id}')">${escHtml(h.title)}</div>`
}
<div class="history-meta">${h.uploadDate ? formatUploadDate(h.uploadDate) : timeAgo(h.createdAt)} &middot; ${h.topicCount} topics</div>
</div>
<button class="history-action-small" onclick="event.stopPropagation(); showExportMenu(this, '${h.id}')" title="Export — PDF, Markdown, or JSON">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
</button>
<button class="history-delete" onclick="deleteSession('${h.id}', event)" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
function renderHistorySidebar() {
const totalSessions = Object.keys(state.historySessions).length;
const { folders, uncategorized } = state.historyMeta;
return `
<div class="history-sidebar-overlay" onclick="toggleHistory()"></div>
<div class="history-sidebar ${historyAnimateIn ? "animate-in" : ""}">
<div class="history-header">
<h2>Library</h2>
<div class="history-actions">
<button class="history-action-btn" onclick="createFolder()" title="New Folder">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
</button>
<button class="close-btn" onclick="toggleHistory()" style="width:30px;height:30px;font-size:16px">&times;</button>
</div>
</div>
<div class="history-list"
ondragover="onListDragOver(event)"
ondrop="onDropToUncategorized(event)">
${totalSessions === 0
? `<div class="history-empty">No sessions yet. Summarize a video to see it here.</div>`
: `
${folders.map(folder => {
const isCollapsed = state.collapsedFolders.has(folder.id);
const isEditing = state.editingFolder === folder.id;
return `
<div class="history-folder">
<div class="history-folder-header" onclick="toggleFolder('${folder.id}')"
ondragover="event.preventDefault(); event.dataTransfer.dropEffect='move'; event.currentTarget.classList.add('drag-over')"
ondragleave="event.currentTarget.classList.remove('drag-over')"
ondrop="event.currentTarget.classList.remove('drag-over'); onDropToFolder(event, '${folder.id}')">
<span class="folder-arrow ${isCollapsed ? "" : "open"}">\u25B6</span>
<span class="folder-icon">\uD83D\uDCC1</span>
${isEditing
? `<input class="folder-name-input" id="folder-edit-${folder.id}"
value="${escHtml(folder.name)}"
onclick="event.stopPropagation()"
onblur="renameFolder('${folder.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingFolder=null;render()}" />`
: `<span class="folder-name">${escHtml(folder.name)}</span>`
}
<span class="folder-count">${folder.items.length}</span>
<span class="folder-actions">
<button class="folder-action" onclick="event.stopPropagation(); state.editingFolder='${folder.id}'; render(); setTimeout(()=>{const el=document.getElementById('folder-edit-${folder.id}');if(el){el.focus();el.select()}},50)" title="Rename">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button class="folder-action danger" onclick="deleteFolder('${folder.id}', event)" title="Delete folder">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</span>
</div>
<div class="folder-items ${isCollapsed ? "collapsed" : ""}">
${folder.items.map(id => renderHistoryItem(id, folder.id)).join("")}
</div>
</div>
`;
}).join("")}
${uncategorized.length > 0 && folders.length > 0 ? `<div class="history-section-label">Unsorted</div>` : ""}
${uncategorized.map(id => renderHistoryItem(id)).join("")}
`
}
</div>
</div>
`;
}
function toggleShowKey() { state.showKey = !state.showKey; render(); }
// Legacy: pre-picker-UI code paths called setApiKey() to update the
// single Gemini key. New flow goes through setProviderOpt('gemini',
// 'apiKey', v). Route this through the same persistence so the two
// storage slots stay consistent.
function setApiKey(v) {
setProviderOpt("gemini", "apiKey", v);
}
// Legacy: setModel() updated the analysis-model dropdown. The new
// picker uses setAnalysisModel(). Keep this for back-compat — it
// updates both the legacy field and the new selection.
function setModel(m) {
state.model = m;
state.analysisModel = m;
saveProviderSelection();
render();
}
function toggleExpandAll() {
state.expandAll = !state.expandAll;
if (!state.expandAll) state.expandedChunks.clear();
render();
}
function toggleChunk(i) {
const wasExpanded = state.expandedChunks.has(i);
// Capture the current scroll offset BEFORE the re-render so we can
// restore it on collapse — render() rebuilds .chunks-scroll and would
// otherwise snap the list back to the top, losing the user's place.
const prevScroll = document.querySelector(".chunks-scroll")?.scrollTop || 0;
// Collapse all others (accordion behavior)
state.expandedChunks.clear();
state.expandAll = false;
// Toggle the clicked one
if (!wasExpanded) state.expandedChunks.add(i);
render();
if (!wasExpanded) {
// Expanding: bring the now-open chunk to the top of the scroll area.
setTimeout(() => {
const chunkEl = document.getElementById("chunk-" + i);
const scrollEl = document.querySelector(".chunks-scroll");
if (chunkEl && scrollEl) {
chunkEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 60);
} else {
// Collapsing: keep the scroll position the user was already at (the
// chunks above the one they collapsed are unchanged, so the same
// offset lands them right back on the segment they were reading).
const scrollEl = document.querySelector(".chunks-scroll");
if (scrollEl) scrollEl.scrollTop = prevScroll;
}
}
function toggleClipPanel() {
// Pro-tier feature: silently no-op if the license lacks the entitlement.
// Existing clipCollection in localStorage is preserved across upgrades.
if (!hasEntitlement("clips")) return;
state.clipPanelOpen = !state.clipPanelOpen;
render();
}
function renderClipPanel() {
const clips = state.clipCollection;
const grouped = {};
clips.forEach((clip, idx) => {
if (!grouped[clip.sessionId]) grouped[clip.sessionId] = [];
grouped[clip.sessionId].push({ ...clip, _idx: idx });
});
let clipListHtml = "";
if (clips.length === 0) {
clipListHtml = '<div style="text-align:center; padding:32px 16px; color:#475569; font-size:13px;">' +
'<p style="margin-bottom:8px;">No clips collected yet.</p>' +
'<p style="font-size:12px;">Click 📎 on any topic or transcript line to add it here.<br>' +
'You can collect clips from multiple videos to build a curated export.</p></div>';
} else {
for (const [sessionId, sessionClips] of Object.entries(grouped)) {
const session = state.historySessions[sessionId];
const sessionTitle = session ? escHtml(session.title) : "Unknown Video";
clipListHtml += '<div style="margin-bottom:16px;">';
clipListHtml += '<div style="font-size:13px; font-weight:600; color:#e2e8f0; margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid #1e293b;">' + sessionTitle + '</div>';
for (const clip of sessionClips) {
const label = clip.entryIndex !== null
? "Topic " + (clip.chunkIndex + 1) + ", line " + (clip.entryIndex + 1)
: "Topic " + (clip.chunkIndex + 1) + " (full)";
const noteHtml = clip.note
? '<div style="font-size:11px; color:#818cf8; font-style:italic; margin-top:2px; padding-left:2px;">' +
'💬 ' + escHtml(clip.note) + '</div>'
: '';
clipListHtml += '<div style="padding:6px 8px; border-radius:6px; margin-bottom:4px;" onmouseover="this.style.background=\'rgba(129,140,248,0.06)\'" onmouseout="this.style.background=\'none\'">' +
'<div style="display:flex; align-items:center; gap:8px;">' +
'<span style="flex:1; font-size:12px; color:#94a3b8;">' + escHtml(label) + '</span>' +
'<button style="border:none; background:none; color:#64748b; cursor:pointer; font-size:11px; padding:2px 6px; border-radius:4px;" onclick="editClipNote(' + clip._idx + ')" title="Edit note">✏️</button>' +
'<button style="border:none; background:none; color:#475569; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px;" onclick="removeFromClipCollection(' + clip._idx + ')" title="Remove">&times;</button>' +
'</div>' +
noteHtml +
'</div>';
}
clipListHtml += '</div>';
}
}
return `
<div class="settings-overlay" onclick="toggleClipPanel()">
<div class="settings-modal" onclick="event.stopPropagation()" style="max-width: 560px;">
<div class="settings-modal-header">
<h2>📎 Clip Collection (${clips.length})</h2>
<div style="display:flex; gap:8px;">
${clips.length > 0 ? `
<button class="expand-btn" onclick="exportClipCollectionPDF()" style="font-size:11px; padding:5px 12px;">Export PDF</button>
<button class="expand-btn" onclick="clearClipCollection()" style="font-size:11px; padding:5px 12px; color:#f87171;">Clear All</button>
` : ""}
<button class="close-btn" onclick="toggleClipPanel()">&times;</button>
</div>
</div>
<div class="settings-modal-body" style="max-height: 60vh; overflow-y: auto;">
${clipListHtml}
</div>
</div>
</div>
`;
}
// ── PDF Export & Clip Collection ────────────────────────────────────────
async function exportSessionPDF(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
if (!data.chunks || data.chunks.length === 0) { showToast("No data to export", "⚠", 3000); return; }
buildPDF(
data.title || "Untitled",
data.videoId,
data.chunks,
data.type || "youtube",
data.speakerNames || null,
data.speakers || null,
);
} catch (e) {
showToast("Export failed: " + e.message, "✕", 4000);
}
}
function exportCurrentPDF() {
if (!state.chunks.length) return;
buildPDF(
state.videoTitle || "Untitled",
state.videoId,
state.chunks,
state.currentType,
state.speakerNames || null,
state.speakers || null,
);
}
// ── Markdown + JSON export helpers ────────────────────────────────
// Both Markdown and JSON exports treat the loaded session as the
// source of truth — same input shape as buildPDF. JSON is the raw
// record (everything saveToHistory persists); Markdown is a
// human-readable serialization with speaker-prefixed transcript
// lines + YouTube deeplinks where applicable. Both honor the
// speakerNames map so the operator's polish-pass-confirmed names
// appear in the export, not the raw Speaker_X cluster IDs.
function chunksToMarkdown(title, videoId, chunks, type, speakerNames, speakers, uploadDate) {
const _names = speakerNames && typeof speakerNames === "object" ? speakerNames : {};
const _speakers = speakers && typeof speakers === "object" ? speakers : null;
const lines = [];
lines.push(`# ${title || "Untitled"}`);
lines.push("");
lines.push(`**Topics:** ${chunks.length} · **Generated:** ${new Date().toLocaleString()}`);
if (uploadDate) lines.push(`**Source date:** ${uploadDate}`);
if (type === "youtube" && videoId) {
lines.push(`**Source:** https://youtube.com/watch?v=${videoId}`);
}
lines.push("");
// Speakers legend
if (_speakers && Object.keys(_speakers).length) {
lines.push("## Speakers");
lines.push("");
const ids = Object.keys(_speakers).sort((a, b) => {
if (a === "Speaker_Unknown") return 1;
if (b === "Speaker_Unknown") return -1;
return a.localeCompare(b);
});
for (const sid of ids) {
const display = pdfChipFullName(sid, _names);
const stats = _speakers[sid] || {};
const secs = Math.round(stats.total_speaking_seconds || 0);
const mins = Math.floor(secs / 60);
const tsec = secs % 60;
const timeStr = mins > 0 ? `${mins}m ${tsec}s` : `${tsec}s`;
const turns = stats.turns || 0;
lines.push(`- **${display}** — ${timeStr} speaking, ${turns} turn${turns !== 1 ? "s" : ""}`);
}
lines.push("");
}
// Topics
lines.push("## Topics");
lines.push("");
chunks.forEach((chunk, i) => {
const startSec = Math.floor(chunk.startTime || 0);
const startStr = formatTime(chunk.startTime || 0);
const next = chunks[i + 1];
const endStr = next
? formatTime(next.startTime || 0)
: (chunk.entries && chunk.entries.length
? formatTime(chunk.entries[chunk.entries.length - 1].offset || 0)
: startStr);
const ytLink = (type === "youtube" && videoId)
? ` [↗](https://youtube.com/watch?v=${videoId}&t=${startSec})`
: "";
lines.push(`### ${i + 1}. ${chunk.title || "(untitled)"} (${startStr}${endStr})${ytLink}`);
lines.push("");
if (chunk.summary) lines.push(chunk.summary);
if (Array.isArray(chunk.entries) && chunk.entries.length) {
lines.push("");
lines.push("<details><summary>Transcript</summary>");
lines.push("");
for (const entry of chunk.entries) {
const ts = formatTime(entry.offset || 0);
const sp = entry.speaker_override || entry.speaker || null;
const who = sp ? pdfChipFullName(sp, _names) : null;
const tag = who ? ` **${who}:**` : "";
const tsec = Math.floor(entry.offset || 0);
const entryLink = (type === "youtube" && videoId)
? `[\\[${ts}\\]](https://youtube.com/watch?v=${videoId}&t=${tsec})`
: `\\[${ts}\\]`;
lines.push(`- ${entryLink}${tag} ${entry.text || ""}`);
}
lines.push("");
lines.push("</details>");
}
lines.push("");
});
return lines.join("\n");
}
function downloadTextFile(filename, mimeType, content) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
a.remove();
URL.revokeObjectURL(url);
}, 0);
}
function safeExportFilename(title) {
return (title || "transcript")
.replace(/[^a-zA-Z0-9_\- ]/g, "")
.trim()
.substring(0, 60) || "transcript";
}
function exportCurrentMarkdown() {
if (!state.chunks.length) return;
const md = chunksToMarkdown(
state.videoTitle || "Untitled",
state.videoId,
state.chunks,
state.currentType,
state.speakerNames || null,
state.speakers || null,
state.videoUploadDate || null,
);
downloadTextFile(safeExportFilename(state.videoTitle) + ".md", "text/markdown;charset=utf-8", md);
showToast("Markdown exported", "📝");
}
function exportCurrentJSON() {
if (!state.chunks.length) return;
const rec = {
title: state.videoTitle || "Untitled",
videoId: state.videoId,
type: state.currentType,
uploadDate: state.videoUploadDate || null,
topicCount: state.chunks.length,
segmentCount: Array.isArray(state.entries) ? state.entries.length : null,
chunks: state.chunks,
speakers: state.speakers || null,
speakerNames: state.speakerNames || null,
exportedAt: new Date().toISOString(),
};
downloadTextFile(
safeExportFilename(state.videoTitle) + ".json",
"application/json;charset=utf-8",
JSON.stringify(rec, null, 2),
);
showToast("JSON exported", "{ }");
}
async function exportSessionMarkdown(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
if (!data.chunks || data.chunks.length === 0) { showToast("No data to export", "⚠", 3000); return; }
const md = chunksToMarkdown(
data.title || "Untitled",
data.videoId,
data.chunks,
data.type || "youtube",
data.speakerNames || null,
data.speakers || null,
data.uploadDate || null,
);
downloadTextFile(safeExportFilename(data.title) + ".md", "text/markdown;charset=utf-8", md);
showToast("Markdown exported", "📝");
} catch (e) {
showToast("Export failed: " + e.message, "✕", 4000);
}
}
async function exportSessionJSON(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
downloadTextFile(
safeExportFilename(data.title) + ".json",
"application/json;charset=utf-8",
JSON.stringify(data, null, 2),
);
showToast("JSON exported", "{ }");
} catch (e) {
showToast("Export failed: " + e.message, "✕", 4000);
}
}
// ── Export menu popover ──────────────────────────────────────────
// Shared 3-option menu (PDF / Markdown / JSON) used by the main
// view's Export button AND the per-row export button in the
// history sidebar. `scope` is "current" to use the loaded session
// OR a sessionId string to fetch a specific saved record. Closes
// on outside click or Escape. Positions itself below the anchor,
// clamped to viewport so it stays visible on mobile.
let _exportMenuCleanup = null;
function closeExportMenu() {
const ex = document.getElementById("export-menu-popover");
if (ex) ex.remove();
if (_exportMenuCleanup) { _exportMenuCleanup(); _exportMenuCleanup = null; }
}
function showExportMenu(anchorEl, scope) {
closeExportMenu();
const popover = document.createElement("div");
popover.id = "export-menu-popover";
popover.style.cssText =
"position:absolute; z-index:5000; background:#0f172a; border:1px solid #334155; " +
"border-radius:8px; padding:4px; box-shadow:0 8px 24px rgba(0,0,0,0.6); " +
"min-width:170px;";
const opts = [
{ icon: "📄", label: "Export PDF", run: () => scope === "current" ? exportCurrentPDF() : exportSessionPDF(scope) },
{ icon: "📝", label: "Export Markdown", run: () => scope === "current" ? exportCurrentMarkdown() : exportSessionMarkdown(scope) },
{ icon: "{ }", label: "Export JSON", run: () => scope === "current" ? exportCurrentJSON() : exportSessionJSON(scope) },
];
for (const opt of opts) {
const btn = document.createElement("button");
btn.type = "button";
btn.style.cssText =
"display:flex; align-items:center; gap:10px; width:100%; padding:9px 12px; " +
"background:transparent; border:none; color:#e2e8f0; font-size:13px; cursor:pointer; " +
"border-radius:5px; text-align:left; min-height:36px;";
btn.onmouseover = () => { btn.style.background = "rgba(255,255,255,0.06)"; };
btn.onmouseout = () => { btn.style.background = "transparent"; };
btn.innerHTML =
'<span style="display:inline-block; width:22px; text-align:center; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px;">' + opt.icon + '</span>' +
'<span>' + opt.label + '</span>';
btn.onclick = (ev) => {
ev.stopPropagation();
closeExportMenu();
opt.run();
};
popover.appendChild(btn);
}
document.body.appendChild(popover);
// Position below the anchor, clamped to viewport. Mobile: if
// the popover would extend below the visible window, flip
// ABOVE the anchor instead.
const rect = anchorEl.getBoundingClientRect();
const popW = popover.offsetWidth;
const popH = popover.offsetHeight;
let left = rect.right - popW + window.scrollX;
if (left < 8) left = 8;
if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8;
let top = rect.bottom + window.scrollY + 4;
if (rect.bottom + popH + 4 > window.innerHeight && rect.top - popH - 4 > 0) {
top = rect.top + window.scrollY - popH - 4;
}
popover.style.left = left + "px";
popover.style.top = top + "px";
const onDocClick = (ev) => { if (!popover.contains(ev.target) && ev.target !== anchorEl) closeExportMenu(); };
const onKey = (ev) => { if (ev.key === "Escape") { ev.preventDefault(); closeExportMenu(); } };
setTimeout(() => {
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
}, 0);
_exportMenuCleanup = () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
};
}
window.showExportMenu = showExportMenu;
// ── Speaker chip palette for PDF ───────────────────────────────
// Print-friendly versions of the on-screen chip palette
// (.speaker-chip.chip-a .. .chip-h in the stylesheet above). The
// on-screen palette uses semi-transparent backgrounds against a
// dark page which look great in a browser but wash out to
// unreadable pastels when rendered on white PDF paper. These
// versions use opaque backgrounds + a more saturated foreground
// so the letter inside the chip stays legible on a printed page.
// Order matches the CSS cycle ("abcdefgh"), so the same
// speaker gets the same color in the dashboard AND the PDF.
const PDF_CHIP_PALETTE = [
{ bg: [254, 226, 226], fg: [185, 28, 28], border: [248, 113, 113] }, // a — red
{ bg: [219, 234, 254], fg: [ 29, 78, 216], border: [ 96, 165, 250] }, // b — blue
{ bg: [220, 252, 231], fg: [ 21, 128, 61], border: [ 74, 222, 128] }, // c — green
{ bg: [254, 243, 199], fg: [180, 83, 9], border: [251, 191, 36] }, // d — amber
{ bg: [243, 232, 255], fg: [109, 40, 217], border: [192, 132, 252] }, // e — purple
{ bg: [224, 242, 254], fg: [ 3, 105, 161], border: [ 56, 189, 248] }, // f — sky
{ bg: [252, 231, 243], fg: [190, 24, 93], border: [244, 114, 182] }, // g — pink
{ bg: [241, 245, 249], fg: [ 71, 85, 105], border: [148, 163, 184] }, // h / Unknown — slate
];
function pdfChipColors(speakerLabel) {
if (speakerLabel === "Speaker_Unknown") return PDF_CHIP_PALETTE[7];
const m = String(speakerLabel || "").match(/^Speaker_([A-Z]+)$/);
if (!m) return PDF_CHIP_PALETTE[0];
let n = 0;
for (const c of m[1]) n = n * 26 + (c.charCodeAt(0) - 64);
n -= 1;
return PDF_CHIP_PALETTE[((n % 8) + 8) % 8];
}
// PDF version of speakerChipDisplay() — uses an explicit
// speakerNames map (passed from the loaded record) instead of
// state.speakerNames, so exports from the history sidebar use
// the names saved with that session and not whatever's loaded
// in the current view.
function pdfChipText(speakerLabel, speakerNames) {
if (speakerLabel === "Speaker_Unknown") return "?";
const m = String(speakerLabel || "").match(/^Speaker_([A-Z]+)$/);
const letter = m ? m[1] : "?";
const name = speakerNames && typeof speakerNames[speakerLabel] === "string" && speakerNames[speakerLabel].trim()
? speakerNames[speakerLabel].trim()
: null;
if (!name) return letter;
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return letter;
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function pdfChipFullName(speakerLabel, speakerNames) {
if (speakerLabel === "Speaker_Unknown") return "Unknown";
const name = speakerNames && typeof speakerNames[speakerLabel] === "string" && speakerNames[speakerLabel].trim()
? speakerNames[speakerLabel].trim()
: null;
if (name) return name;
const m = String(speakerLabel || "").match(/^Speaker_([A-Z]+)$/);
return m ? "Speaker " + m[1] : speakerLabel;
}
// Renders a single rounded chip and returns its width (so callers
// can advance x for the next element). chipText is the short
// letter/initials shown inside; speakerLabel drives the color.
// `chipText` may be empty — then nothing is drawn (returns 0).
function drawPDFChip(doc, speakerLabel, chipText, x, baselineY) {
if (!chipText) return 0;
const colors = pdfChipColors(speakerLabel);
// Approximate the on-screen chip: 6mm wide minimum, 4mm tall.
// Add extra width for 2-character chips (initials like "MH").
const chipH = 4.2;
const padX = 1.3;
doc.setFont("helvetica", "bold"); doc.setFontSize(7.5);
const textW = doc.getTextWidth(chipText);
const chipW = Math.max(5.5, textW + padX * 2);
// Position chip so its TOP sits ~1.3mm above the text baseline
// (visually centers the chip on text of fontSize 8-9).
const top = baselineY - chipH + 1.0;
doc.setFillColor(...colors.bg);
doc.setDrawColor(...colors.border);
doc.setLineWidth(0.2);
doc.roundedRect(x, top, chipW, chipH, 0.8, 0.8, "FD");
doc.setTextColor(...colors.fg);
// Center the text inside the chip.
doc.text(chipText, x + chipW / 2, top + chipH - 1.1, { align: "center" });
return chipW;
}
// ── Shared PDF helpers ──────────────────────────────────────────────
function createPDFDoc() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pw = doc.internal.pageSize.getWidth();
const ph = doc.internal.pageSize.getHeight();
const margin = 20;
const maxW = pw - margin * 2;
let y = margin;
let pageNum = 1;
// Colors
const C = {
title: [15, 23, 42], // near-black
heading: [30, 41, 59], // dark slate
body: [51, 65, 85], // medium slate
meta: [100, 116, 139], // gray
light: [148, 163, 184], // light gray
link: [37, 99, 235], // blue
accent: [99, 102, 241], // indigo
noteBg: [248, 250, 252], // very light gray
noteBar: [99, 102, 241], // indigo bar
divider: [226, 232, 240], // light border
};
function addFooter() {
doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(...C.light);
doc.text("YouTube Transcript Summarizer", margin, ph - 8);
doc.text("Page " + pageNum, pw - margin, ph - 8, { align: "right" });
}
function checkPage(needed) {
if (y + needed > ph - 16) {
addFooter();
doc.addPage(); y = margin; pageNum++;
return true;
}
return false;
}
// Draw underlined link text
function drawLink(text, x, yPos, url) {
doc.setTextColor(...C.link);
doc.textWithLink(text, x, yPos, { url });
// Underline
const tw = doc.getTextWidth(text);
doc.setDrawColor(...C.link); doc.setLineWidth(0.2);
doc.line(x, yPos + 0.8, x + tw, yPos + 0.8);
}
// Draw a shaded note box with left accent bar
function drawNoteBox(noteText, x, boxWidth) {
const noteLines = doc.splitTextToSize(noteText, boxWidth - 14);
const lineH = 4;
const boxH = noteLines.length * lineH + 8;
checkPage(boxH + 4);
// Background fill
doc.setFillColor(...C.noteBg);
doc.roundedRect(x, y - 2, boxWidth, boxH, 2, 2, "F");
// Left accent bar
doc.setFillColor(...C.noteBar);
doc.rect(x, y - 2, 1.5, boxH, "F");
// Note label
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
doc.setTextColor(...C.accent);
doc.text("MY NOTE", x + 6, y + 2);
// Note text
doc.setFont("helvetica", "normal"); doc.setFontSize(9);
doc.setTextColor(...C.heading);
let ny = y + 6;
noteLines.forEach(line => { doc.text(line, x + 6, ny); ny += lineH; });
y += boxH + 3;
}
return { doc, pw, ph, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox, getY: () => y, setY: (v) => { y = v; }, addY: (v) => { y += v; } };
}
function buildPDF(title, videoId, chunks, type, speakerNames, speakers) {
if (!window.jspdf) {
showToast("PDF library not loaded yet — please try again in a moment", "⚠", 4000);
return;
}
// Normalize speakerNames (callers may pass undefined / null).
const _speakerNames = speakerNames && typeof speakerNames === "object" ? speakerNames : {};
const _speakers = speakers && typeof speakers === "object" ? speakers : null;
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(20); doc.setTextColor(...C.title);
const titleLines = doc.splitTextToSize(title, maxW);
titleLines.forEach(line => { checkPage(9); doc.text(line, margin, p.getY()); p.addY(8); });
p.addY(2);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(chunks.length + " topics | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line under header
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(8);
// ── Speakers legend ──
// When the saved record carries diarization metadata, render
// a compact "SPEAKERS" row above the topics so the reader has
// a key for the chips that appear on each transcript line.
// Skips entirely when no diarization ran (older sessions /
// diarization disabled / no fingerprints collected).
if (_speakers && Object.keys(_speakers).length > 0) {
const speakerIds = Object.keys(_speakers).sort((a, b) => {
if (a === "Speaker_Unknown") return 1;
if (b === "Speaker_Unknown") return -1;
return a.localeCompare(b);
});
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("SPEAKERS", margin, p.getY()); p.addY(5);
let lx = margin;
const gap = 4;
for (const sid of speakerIds) {
const chipText = pdfChipText(sid, _speakerNames);
const fullName = pdfChipFullName(sid, _speakerNames);
const stats = _speakers[sid] || {};
const secs = Math.round(stats.total_speaking_seconds || 0);
const mins = Math.floor(secs / 60);
const tsec = secs % 60;
const timeStr = mins > 0 ? mins + "m " + tsec + "s" : tsec + "s";
const label = " " + fullName + " · " + timeStr;
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const labelW = doc.getTextWidth(label);
// 6mm reserved for the chip itself (matches drawPDFChip's
// min width). Wrap to next line if we'd run off the page.
const entryW = 6.5 + labelW + gap;
if (lx + entryW > pw - margin) {
p.addY(6);
checkPage(6);
lx = margin;
}
const baselineY = p.getY() + 3;
const chipW = drawPDFChip(doc, sid, chipText, lx, baselineY);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
doc.text(label, lx + chipW, baselineY);
lx += chipW + labelW + gap;
}
p.addY(7);
// Light divider between legend and topics
doc.setDrawColor(...C.divider); doc.setLineWidth(0.15);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(5);
}
// ── Chunks ──
chunks.forEach((chunk, ci) => {
checkPage(22);
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + startSec : null;
// Topic number pill
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("TOPIC " + (ci + 1), margin, p.getY());
// Time range
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, margin + 22, p.getY());
p.addY(5);
// Topic title
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...C.heading);
const titleL = doc.splitTextToSize(chunk.title, maxW);
titleL.forEach((line, li) => {
checkPage(6);
if (li === 0 && ytUrl) {
drawLink(line, margin, p.getY(), ytUrl);
} else {
doc.text(line, margin, p.getY());
}
p.addY(5.5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9.5); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, maxW);
sumLines.forEach(line => { checkPage(5); doc.text(line, margin, p.getY()); p.addY(4.2); });
p.addY(3);
// Transcript entries
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
chunk.entries.forEach(entry => {
checkPage(5);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + sec : null;
// Speaker chip (drawn first; advances x-cursor by chipW
// for the timestamp + text). Skipped when no speaker
// attribution exists on this entry (older sessions /
// diarization off / unassigned line).
const speaker = entry.speaker_override || entry.speaker || null;
const chipText = speaker ? pdfChipText(speaker, _speakerNames) : "";
const baselineY = p.getY();
let cursorX = margin + 2;
if (chipText) {
const chipW = drawPDFChip(doc, speaker, chipText, cursorX, baselineY);
cursorX += chipW + 1.5;
}
// Timestamp
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", cursorX, baselineY, entryUrl);
const tsW = doc.getTextWidth("[" + ets + "]");
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const textStartX = cursorX + tsW + 2;
// Text after timestamp — wrap budget shrinks by the
// additional left indent (chip + ts widths).
const textLines = doc.splitTextToSize(entry.text, maxW - (textStartX - margin) - 2);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, textStartX, p.getY());
} else {
checkPage(3.8);
doc.text(line, textStartX, p.getY());
}
p.addY(3.6);
});
} else {
// No clickable timestamp — render chip + "[ts] text" inline.
const tsText = "[" + ets + "] ";
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const tsW = doc.getTextWidth(tsText);
const textStartX = cursorX + tsW;
doc.text(tsText, cursorX, baselineY);
const textLines = doc.splitTextToSize(entry.text, maxW - (textStartX - margin) - 2);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, textStartX, p.getY());
} else {
checkPage(3.8);
doc.text(line, textStartX, p.getY());
}
p.addY(3.6);
});
}
});
p.addY(4);
// Separator
if (ci < chunks.length - 1) {
checkPage(6);
doc.setDrawColor(...C.divider); doc.setLineWidth(0.15);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
}
});
addFooter();
const safeName = title.replace(/[^a-zA-Z0-9_\- ]/g, "").trim().substring(0, 60) || "transcript";
doc.save(safeName + ".pdf");
showToast("PDF exported", "📄");
} catch (err) {
console.error("PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
// ── Clip Collection ───────────────────────────────────────────────────────
function addToClipCollection(sessionId, chunkIndex, entryIndex) {
// Pro-tier feature. Defense-in-depth: even if a Core user reaches this
// (e.g. via stale UI before re-render), refuse the add and prompt upgrade.
if (!hasEntitlement("clips")) {
showToast("Clips are a Pro feature. Upgrade to unlock.", "🔒", 3500);
return;
}
// Check if already in collection
const exists = state.clipCollection.find(c =>
c.sessionId === sessionId && c.chunkIndex === chunkIndex &&
(entryIndex === undefined ? c.entryIndex === null : c.entryIndex === entryIndex)
);
if (exists) {
showToast("Already in clip collection", "", 2000);
return;
}
// Show notes prompt modal
showClipNotePrompt(sessionId, chunkIndex, entryIndex !== undefined ? entryIndex : null);
}
function showClipNotePrompt(sessionId, chunkIndex, entryIndex) {
// Create overlay
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>📎 Add Clip</h2>' +
'<button class="close-btn" id="clip-note-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<label class="field-label" style="margin-top:0">Note (optional)</label>' +
'<textarea id="clip-note-input" rows="3" placeholder="Why is this interesting? Add context for when you share it..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;"></textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-note-skip" class="expand-btn" style="font-size:12px; padding:8px 16px;">Skip</button>' +
'<button id="clip-note-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Add Clip</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-note-input");
setTimeout(() => input.focus(), 50);
function finish(note) {
state.clipCollection.push({
sessionId,
chunkIndex,
entryIndex,
note: (note || "").trim() || null,
});
saveClipCollection();
overlay.remove();
showToast("Clip added (" + state.clipCollection.length + " total)", "📎");
render();
}
function cancel() { overlay.remove(); }
document.getElementById("clip-note-save").onclick = () => finish(input.value);
document.getElementById("clip-note-skip").onclick = () => finish("");
document.getElementById("clip-note-cancel").onclick = cancel;
overlay.onclick = cancel;
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finish(input.value); } };
}
function editClipNote(index) {
const clip = state.clipCollection[index];
if (!clip) return;
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>✏️ Edit Note</h2>' +
'<button class="close-btn" id="clip-edit-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<textarea id="clip-edit-input" rows="3" placeholder="Add your thoughts about this clip..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;">' +
escHtml(clip.note || "") + '</textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-edit-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-edit-input");
setTimeout(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 50);
function save() {
state.clipCollection[index].note = input.value.trim() || null;
saveClipCollection();
overlay.remove();
render();
}
document.getElementById("clip-edit-save").onclick = save;
document.getElementById("clip-edit-cancel").onclick = () => overlay.remove();
overlay.onclick = () => overlay.remove();
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } };
}
function removeFromClipCollection(index) {
state.clipCollection.splice(index, 1);
saveClipCollection();
render();
}
function clearClipCollection() {
state.clipCollection = [];
state.clipPanelOpen = false;
saveClipCollection();
render();
showToast("Clip collection cleared", "🗑");
}
function saveClipCollection() {
localStorage.setItem("recap-clips", JSON.stringify(state.clipCollection));
}
function loadClipCollection() {
try {
const saved = localStorage.getItem("recap-clips");
if (saved) state.clipCollection = JSON.parse(saved);
} catch {}
}
async function exportClipCollectionPDF() {
if (state.clipCollection.length === 0) { showToast("No clips collected", "⚠", 3000); return; }
if (!window.jspdf) { showToast("PDF library not loaded yet — please try again", "⚠", 4000); return; }
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(22); doc.setTextColor(...C.title);
doc.text("Curated Clips", margin, p.getY()); p.addY(9);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(state.clipCollection.length + " clips | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(10);
// Fetch all needed sessions
const sessionIds = [...new Set(state.clipCollection.map(c => c.sessionId))];
const sessionData = {};
for (const sid of sessionIds) {
try {
const res = await fetch(API_BASE + "/api/history/" + sid);
sessionData[sid] = await res.json();
} catch { sessionData[sid] = null; }
}
// ── Render clips ──
let currentSessionId = null;
let clipNum = 0;
for (const clip of state.clipCollection) {
const session = sessionData[clip.sessionId];
if (!session) continue;
const chunk = session.chunks?.[clip.chunkIndex];
if (!chunk) continue;
clipNum++;
// Session header if new session
if (clip.sessionId !== currentSessionId) {
checkPage(18);
if (currentSessionId !== null) { p.addY(6); }
// Session divider with title
doc.setDrawColor(...C.divider); doc.setLineWidth(0.3);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text("SOURCE", margin, p.getY()); p.addY(4);
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...C.title);
const stitleLines = doc.splitTextToSize(session.title || "Untitled", maxW);
stitleLines.forEach(line => { checkPage(7); doc.text(line, margin, p.getY()); p.addY(6); });
p.addY(4);
currentSessionId = clip.sessionId;
}
// ── User note (shaded box with left accent bar) ──
if (clip.note) {
drawNoteBox(clip.note, margin, maxW);
}
// ── Clip content (indented under the note) ──
const indent = clip.note ? 4 : 0; // indent content when there's a note
const contentX = margin + indent;
const contentW = maxW - indent;
checkPage(14);
const isYt = (session.type || "youtube") === "youtube";
const vid = session.videoId;
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + startSec : null;
// Topic label + time
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("CLIP " + clipNum, contentX, p.getY());
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, contentX + 18, p.getY());
p.addY(5);
// Topic title with link
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...C.heading);
const ctLines = doc.splitTextToSize(chunk.title, contentW);
ctLines.forEach((line, li) => {
checkPage(5.5);
if (li === 0 && ytUrl) {
drawLink(line, contentX, p.getY(), ytUrl);
} else {
doc.text(line, contentX, p.getY());
}
p.addY(5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, contentW);
sumLines.forEach(line => { checkPage(4.5); doc.text(line, contentX, p.getY()); p.addY(4); });
p.addY(2);
// Transcript entries
const entries = clip.entryIndex !== null ? [chunk.entries[clip.entryIndex]].filter(Boolean) : chunk.entries;
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
entries.forEach(entry => {
checkPage(4);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + sec : null;
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", contentX + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const textLines = doc.splitTextToSize(entry.text, contentW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, contentX + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, contentX + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, contentW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, contentX + 2, p.getY()); p.addY(3.6); });
}
});
p.addY(6);
// Light separator between clips
doc.setDrawColor(...C.divider); doc.setLineWidth(0.1);
const sepX1 = margin + 20; const sepX2 = pw - margin - 20;
doc.line(sepX1, p.getY(), sepX2, p.getY()); p.addY(6);
}
addFooter();
doc.save("curated-clips.pdf");
showToast("Clips PDF exported", "📄");
} catch (err) {
console.error("Clip PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
function escHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Keysat license helpers ───────────────────────────────────────────────
function hasEntitlement(name) {
return !!(state.license && state.license.entitlements && state.license.entitlements.includes(name));
}
// Any paid tier — Pro or Max. Both flip the "free user" gating off.
function isLicensed() {
return (
state.license &&
state.license.state === "licensed" &&
(hasEntitlement("pro") || hasEntitlement("max"))
);
}
// Used by the upgrade banner / toolbar to decide whether to show an
// Upgrade CTA. "Pro tier OR above" means the user has at least the
// baseline paid feature set (subscriptions + auto-queue). Today
// both Pro and Max licenses include `subscriptions`, so this is
// equivalent to "is paid" — kept as a distinct helper so a future
// sub-Pro tier (e.g. a "starter" license without subscriptions)
// can drop the subscriptions check and still pass isLicensed().
function isProTier() {
return isLicensed() && hasEntitlement("subscriptions");
}
// Subscriptions + auto-queue are now per-tenant: any user whose tier
// carries the "subscriptions" entitlement (Pro/Max, or the operator)
// gets their own. Gates every subscription affordance (button, queue
// panel, poll, settings section).
function canUseSubscriptions() {
return hasEntitlement("subscriptions");
}
// Multi-tenant account state. Cheap call, hits SQLite, returns
// current user / trial / anonymous + recap_mode flag. Frontend
// uses recap_mode to decide whether to show signin/signout UI
// and whether to gate operator-only settings sections.
async function loadAccount() {
try {
const res = await fetch(`${API_BASE}/api/account/whoami`);
const data = await res.json();
state.account = {
loaded: true,
recap_mode: data.recap_mode || "single",
state: data.state || "anonymous",
user: data.user || null,
trial: data.trial || null,
// Operator's configured trial allowance — populated when
// state=anonymous so the UI can show "N free credits
// available" before the visitor has even hit Summarize.
// 0 means trials are disabled (visitor must sign up to use
// the app).
available_trial_credits: data.available_trial_credits ?? 0,
// Operator's post-signup grant — drives the Free-tier card
// copy on the tier signup modal. 0 means "trial credits
// transfer but you don't get a signup bonus."
signup_grant_credits: data.signup_grant_credits ?? 0,
// Server-set when the visitor's IP can't mint a new trial
// cookie (operator's trials_per_ip_lifetime cap reached).
// Server has already forced available_trial_credits to 0;
// this field tells the UI WHY so it can show honest copy
// ("trial used up — sign up / buy") instead of a generic
// "0 free credits" pill that reads like a config error.
trial_blocked_reason: data.trial_blocked_reason || null,
};
} catch {
// On failure, leave state.account at its defaults so the
// UI behaves like single-mode (no surprises). The next
// render() will still try to fetch on demand for actions
// that need an updated state.
}
}
// ── Admin panel data loaders + actions ───────────────────────────────
// All gated server-side on req.user.is_admin (403 otherwise). The
// frontend gates UI visibility on isAdmin() so non-admins never see
// the buttons; the server is the source of truth.
async function loadAdminTenants() {
state.ops.tenantsLoading = true;
state.ops.tenantsError = null;
render();
try {
const res = await fetch(`${API_BASE}/api/admin/tenants`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.ops.tenants = data.tenants || [];
// Whether this server can set relay-owned tiers (has the operator
// key). Drives whether the per-row "Tier" control is shown.
state.ops.operatorKeyConfigured = !!data.relay_operator_key_configured;
} catch (e) {
state.ops.tenantsError = e.message || "failed_to_load";
} finally {
state.ops.tenantsLoading = false;
render();
}
}
async function loadAdminActivity(hours) {
if (typeof hours === "number") state.ops.activityHours = hours;
state.ops.activityLoading = true;
state.ops.activityError = null;
render();
try {
const res = await fetch(
`${API_BASE}/api/admin/recent-signups?hours=${state.ops.activityHours}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.ops.activity = await res.json();
} catch (e) {
state.ops.activityError = e.message || "failed_to_load";
} finally {
state.ops.activityLoading = false;
render();
}
}
// Open / close the per-row "+ N credits" inline form. Toggling
// the same row collapses it; opening a different row replaces.
function toggleGrantCreditsRow(userId) {
if (state.ops.grantOpenFor === userId) {
state.ops.grantOpenFor = null;
state.ops.grantAmount = "";
} else {
state.ops.grantOpenFor = userId;
state.ops.grantAmount = "";
}
render();
}
async function submitGrantCredits(userId) {
const amount = parseInt(state.ops.grantAmount, 10);
if (!Number.isFinite(amount) || amount <= 0) {
showToast("Enter a positive number of credits", "!", 3000);
return;
}
state.ops.grantBusy = true;
render();
try {
const res = await fetch(
`${API_BASE}/api/admin/tenants/${encodeURIComponent(userId)}/grant`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || "failed");
showToast(`Granted ${amount} credits — balance ${data.balance}`, "✓");
state.ops.grantOpenFor = null;
state.ops.grantAmount = "";
// Refresh the tenant list so the row's balance updates inline.
await loadAdminTenants();
} catch (e) {
showToast("Grant failed: " + (e.message || "unknown"), "!", 4000);
} finally {
state.ops.grantBusy = false;
render();
}
}
// Open / close the per-row tier selector. Mutually exclusive with the
// credits form so two inline panels don't stack on one row.
function toggleTierRow(userId) {
if (state.ops.tierOpenFor === userId) {
state.ops.tierOpenFor = null;
} else {
state.ops.tierOpenFor = userId;
state.ops.grantOpenFor = null;
}
render();
}
// Set a tenant's subscription tier. The server writes the relay-owned
// tier first (authoritative) then caches it locally, so a 502 here
// means the relay/operator-key needs attention — surface it verbatim.
async function setTenantTier(userId, tier) {
state.ops.tierBusy = true;
render();
try {
const res = await fetch(
`${API_BASE}/api/admin/tenants/${encodeURIComponent(userId)}/tier`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier }),
},
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || "failed");
showToast(`Tier set to ${String(tier).toUpperCase()}`, "✓");
state.ops.tierOpenFor = null;
// Refresh so the badge updates inline.
await loadAdminTenants();
} catch (e) {
showToast("Set tier failed: " + (e.message || "unknown"), "!", 5000);
} finally {
state.ops.tierBusy = false;
render();
}
}
async function revokeAllSessionsForTenant(userId, email) {
const ok = confirm(
`Sign ${email || "this user"} out of every active session?\n\nThey can sign back in via magic link.`,
);
if (!ok) return;
try {
const res = await fetch(
`${API_BASE}/api/admin/tenants/${encodeURIComponent(userId)}/sessions`,
{ method: "DELETE" },
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error || "failed");
showToast(`Revoked ${data.revoked || 0} session(s)`, "✓");
await loadAdminTenants();
} catch (e) {
showToast("Revoke failed: " + (e.message || "unknown"), "!", 4000);
}
}
// ── My-sessions (tenant-side) ────────────────────────────────────────
async function loadMySessions() {
state.mySessions.loading = true;
state.mySessions.error = null;
render();
try {
const res = await fetch(`${API_BASE}/api/account/sessions`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.mySessions.rows = data.sessions || [];
state.mySessions.currentId = data.current_session_id || null;
} catch (e) {
state.mySessions.error = e.message || "failed_to_load";
} finally {
state.mySessions.loading = false;
render();
}
}
async function loadMyDigest() {
state.digest.loading = true;
render();
try {
const res = await fetch(`${API_BASE}/api/account/digest`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
state.digest.enabled = !!data.enabled;
} catch (e) {
// Leave enabled null; the block keeps showing "Loading…" rather
// than asserting a state we couldn't confirm.
} finally {
state.digest.loading = false;
render();
}
}
async function setDigestEnabled(enabled) {
const prev = state.digest.enabled;
// Optimistic flip so the switch responds instantly; revert on error.
state.digest.enabled = enabled;
state.digest.saving = true;
render();
try {
const res = await fetch(`${API_BASE}/api/account/digest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
showToast(enabled ? "Daily digest on" : "Daily digest off", "✓");
} catch (e) {
state.digest.enabled = prev;
showToast("Couldn't save that — try again", "!");
} finally {
state.digest.saving = false;
render();
}
}
async function revokeMySession(sessionId) {
try {
const res = await fetch(
`${API_BASE}/api/account/sessions/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" },
);
if (!res.ok) throw new Error("failed");
showToast("Session revoked", "✓");
await loadMySessions();
} catch (e) {
showToast("Revoke failed", "!");
}
}
async function revokeOtherSessions() {
const ok = confirm(
"Sign out from every other device (keeping this one signed in)?",
);
if (!ok) return;
try {
const res = await fetch(
`${API_BASE}/api/account/sessions/revoke-others`,
{ method: "POST" },
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || "failed");
showToast(`Signed out ${data.revoked || 0} other session(s)`, "✓");
await loadMySessions();
} catch (e) {
showToast("Revoke failed: " + (e.message || "unknown"), "!", 4000);
}
}
// ── Password set / change / clear (tenant lite settings) ────────────
// Magic-link is primary auth; password is an optional faster-signin
// add-on. We show different copy based on whether the user already
// has one set (state.account.user.has_password).
async function setPassword(plain) {
const res = await fetch(`${API_BASE}/api/account/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: plain }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
return data;
}
async function clearPassword() {
const res = await fetch(`${API_BASE}/api/account/password`, {
method: "DELETE",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
return data;
}
async function submitPasswordSet() {
const pwd = (state.passwordInput || "").trim();
if (pwd.length < 8) {
showToast("Use at least 8 characters.", "!", 3000);
return;
}
state.passwordBusy = true;
render();
try {
await setPassword(pwd);
// Refresh whoami so has_password flips to true and the UI
// switches from "Set password" to "Change / Clear".
await loadAccount();
state.passwordInput = "";
state.passwordOpen = false;
showToast("Password set", "✓");
} catch (e) {
showToast(e.message || "Couldn't set password", "!", 4000);
} finally {
state.passwordBusy = false;
render();
}
}
async function submitPasswordClear() {
if (
!confirm(
"Remove your password? You'll still be able to sign in via magic link.",
)
) {
return;
}
state.passwordBusy = true;
render();
try {
await clearPassword();
await loadAccount();
showToast("Password removed", "✓");
} catch (e) {
showToast(e.message || "Couldn't clear password", "!", 4000);
} finally {
state.passwordBusy = false;
render();
}
}
function togglePasswordEditor() {
state.passwordOpen = !state.passwordOpen;
state.passwordInput = "";
render();
}
function renderPasswordBlock() {
if (!state.account?.user) return "";
const hasPwd = !!state.account.user.has_password;
const open = !!state.passwordOpen;
const inputBlock = open
? `
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px;">
<input type="password" placeholder="At least 8 characters" minlength="8"
value="${escHtml(state.passwordInput || "")}"
oninput="state.passwordInput=this.value"
onkeydown="if(event.key==='Enter')submitPasswordSet()"
style="padding:9px 12px;font-size:13px;background:#0a0e1a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;outline:none;" />
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button onclick="submitPasswordSet()" ${state.passwordBusy ? "disabled" : ""}
style="padding:8px 16px;font-size:12px;font-weight:600;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;${state.passwordBusy ? "opacity:0.5;cursor:not-allowed;" : ""}">
${state.passwordBusy ? "Saving..." : hasPwd ? "Update" : "Set password"}
</button>
<button onclick="togglePasswordEditor()"
style="padding:8px 14px;font-size:12px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">
Cancel
</button>
</div>
</div>`
: "";
const description = hasPwd
? "You can sign in with email + password OR with a magic link."
: "Set a password for faster sign-ins. You can still use the magic link any time.";
const ctaLabel = hasPwd
? open ? "Close" : "Change password"
: open ? "Close" : "Set a password";
return `
<label class="field-label" style="margin-top:14px;">Password</label>
<div class="ytdlp-status" style="flex-direction:column;align-items:stretch;gap:6px;border-color:#334155;background:rgba(30,41,59,0.3);padding:14px;">
<div style="font-size:12px;color:#cbd5e1;line-height:1.55;">${description}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button onclick="togglePasswordEditor()"
style="padding:7px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#cbd5e1;border:1px solid #334155;border-radius:6px;cursor:pointer;">
${ctaLabel}
</button>
${hasPwd ? `<button onclick="submitPasswordClear()" ${state.passwordBusy ? "disabled" : ""}
style="padding:7px 14px;font-size:12px;background:transparent;color:#fca5a5;border:1px solid rgba(248,113,113,0.40);border-radius:6px;cursor:pointer;${state.passwordBusy ? "opacity:0.5;cursor:not-allowed;" : ""}">
Remove
</button>` : ""}
</div>
${inputBlock}
</div>
`;
}
// ── Self-delete (tenant lite settings) ───────────────────────────────
async function deleteMyAccount() {
const email = state.account?.user?.email || "your account";
// Two-step confirm so a stray click can't take an account down:
// first a yes/no, then a "type DELETE" check.
if (
!confirm(
`Delete ${email}?\n\nYour library, sessions, and license attachment will be erased. This cannot be undone.`,
)
) {
return;
}
const typed = prompt(`Type DELETE in all caps to confirm.`);
if (typed !== "DELETE") {
showToast("Cancelled — your account is safe.", "");
return;
}
try {
const res = await fetch(`${API_BASE}/api/account`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
// Cookie already cleared server-side. Reload to the anonymous
// landing page; the page itself will pick up the cookie-less
// state via /api/account/whoami.
window.location.href = "/";
} catch (e) {
showToast("Delete failed: " + (e.message || "unknown"), "!", 5000);
}
}
// ── Admin: delete tenant ─────────────────────────────────────────────
async function adminDeleteTenant(userId, email) {
const safeEmail = email || "(no email)";
if (
!confirm(
`Permanently delete ${safeEmail}?\n\nTheir library, sessions, and license attachment will be erased. The relay's per-license credit pool (if any) stays intact at the relay.\n\nThis can't be undone.`,
)
) {
return;
}
try {
const res = await fetch(
`${API_BASE}/api/admin/tenants/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
showToast(`Deleted ${safeEmail}`, "✓");
await loadAdminTenants();
} catch (e) {
showToast("Delete failed: " + (e.message || "unknown"), "!", 5000);
}
}
function isMulti() {
return state.account?.recap_mode === "multi";
}
function isAdmin() {
// Default to TRUE so single-mode + early-load (account not
// fetched yet) keep showing the full settings UI. Only flip
// to false once we've confirmed we're in multi-mode AND the
// current user is NOT an admin.
if (!isMulti()) return true;
return !!state.account?.user?.is_admin;
}
async function loadLicenseStatus() {
try {
const res = await fetch(`${API_BASE}/api/license-status`);
const data = await res.json();
state.license = {
loaded: true,
state: data.state || "unlicensed",
reason: data.reason || null,
licenseId: data.licenseId || null,
entitlements: data.entitlements || [],
expiresAt: data.expiresAt || null,
isTrial: !!data.isTrial,
productSlug: data.productSlug || "recap",
keysatBaseUrl: data.keysatBaseUrl || "",
};
} catch {
state.license = {
...state.license,
loaded: true,
state: "unlicensed",
reason: "license_status_unreachable",
};
}
}
async function activateLicense() {
const key = (state.licenseActivationKey || "").trim();
if (!key) {
state.licenseActivationError = "Paste a license key first.";
render();
return;
}
state.licenseActivating = true;
state.licenseActivationError = null;
render();
try {
const res = await fetch(`${API_BASE}/api/license/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: key }),
});
const data = await res.json();
if (res.ok && data.ok) {
state.license = {
loaded: true,
state: data.state,
reason: data.reason,
licenseId: data.licenseId,
entitlements: data.entitlements || [],
expiresAt: data.expiresAt,
isTrial: !!data.isTrial,
productSlug: data.productSlug || "recap",
keysatBaseUrl: data.keysatBaseUrl || "",
};
state.licenseActivationKey = "";
state.licenseActivationError = null;
showToast("License activated.", "✓");
// Now that we're licensed, kick off the loads we deferred at boot.
await loadAfterLicensed();
} else {
state.licenseActivationError = data.message || data.reason || "Activation failed.";
}
} catch (e) {
state.licenseActivationError = "Could not reach the server.";
} finally {
state.licenseActivating = false;
render();
}
}
async function deactivateLicense() {
if (!confirm("Remove the license from this server? You'll need to paste the key again to re-activate.")) return;
try {
await fetch(`${API_BASE}/api/license/deactivate`, { method: "POST" });
} catch {}
await loadLicenseStatus();
// Clear in-memory data that the deactivated user can no longer see.
state.subscriptions = [];
state.subsLoaded = false;
state.historySessions = {};
state.historyMeta = { folders: [], uncategorized: [] };
state.historyLoaded = false;
render();
}
async function loadAfterLicensed() {
// Lightweight version of init's secondary loads. Only fetches what
// the current entitlements actually permit.
await loadHistory().catch(() => {});
if (canUseSubscriptions()) {
await loadSubscriptions().catch(() => {});
try { await pollAutoQueue(); } catch {}
}
// Force-refresh the relay snapshot so the toolbar pill flips
// from "6 relay credits · core" to the new tier's quota
// immediately. Without this, the operator's Recap UI would
// stay on the cached core-tier values for up to 60s (next
// poll tick) and the relay-side tier validation wouldn't
// even happen until then. Forced refresh bypasses Recap's
// 10s relayState cache; the relay's keysat-client validates
// the newly-saved license proof on its end.
await loadRelayStatus(true).catch(() => {});
render();
}
function upgradeToProUrl() {
const base = state.license.keysatBaseUrl || "https://licensing.keysat.xyz";
return `${base.replace(/\/$/, "")}/buy/${state.license.productSlug || "recap"}`;
}
function showToast(message, icon = "✓", duration = 4000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg">${escHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300);
}, duration);
}
// ── Init ─────────────────────────────────────────────────────────────────
// Load persisted clip collection
loadClipCollection();
// Boot sequence:
// 1. Fetch admin-status. If the gate is enabled and we're not
// authed, render the login screen and stop. The user runs
// initAfterAdminAuth() from submitAdminLogin() once they sign
// in.
// 2. Otherwise, kick off the normal license + health loads.
(async () => {
try {
await loadAdminStatus();
} catch {}
if (state.admin.enabled && !state.admin.authed) {
render();
return;
}
await initAfterAdminAuth();
})();
// Everything that used to run unconditionally at boot but is gated
// by the admin login. Idempotent — safe to call again after a
// successful login.
async function initAfterAdminAuth() {
try {
const [_, health, net, _account, _job, _discover] = await Promise.all([
loadLicenseStatus(),
fetch(`${API_BASE}/api/health`).then(r => r.json()),
fetch(`${API_BASE}/api/network-mode`).then(r => r.json()).catch(() => null),
// Multi-tenant account state. Lightweight (SQLite read +
// cookie check). Runs alongside the rest of boot so the
// first render branches on the right mode without a flash
// of single-mode UI.
loadAccount(),
// Survives browser refresh: if the server has a free-tier job
// running, the banner renders with what's processing + Cancel,
// and the activity log is repopulated from the server's
// buffered log entries so a refresh doesn't drop everything
// the user has already seen.
loadCurrentJob({ withLogs: true }),
// Pre-fill picker UI placeholders for providers the server
// can auto-detect (Ollama via StartOS dependency today).
loadProviderDiscovery(),
// Relay credit balance + tier (cached server-side from the
// last relay call). Surfaces the "N credits remaining" pill
// near the picker; safe to call before the relay is even
// configured (returns nulls + configured:false).
loadRelayStatus(),
// Per-field server-side credential status so the picker UI
// can hint at server-configured keys + show Delete buttons
// even when localStorage is empty.
loadProviderServerStatus(),
// Live tier-quota policy from the relay so dynamic copy
// (activation screen credit count, etc.) reflects the
// operator's current relay config without a Recap update.
loadRelayPolicy(),
]);
if (state.currentJob) startCurrentJobPoll();
startRelayStatusPoll();
state.hasServerKey = !!health.hasServerKey;
state.installId = health.installId || null;
if (!health.installed) {
state.ytdlpVersion = false;
state.error = "Backend is running but yt-dlp is not installed.\nInstall it with: brew install yt-dlp (or) pip install yt-dlp";
} else {
state.ytdlpVersion = health.version;
state.ytdlpLatest = health.latestVersion;
state.ytdlpUpdateAvailable = health.updateAvailable || false;
}
if (health.cookies) {
state.cookieMethod = health.cookies.method || "none";
state.cookieFileAgeDays = health.cookies.fileAgeDays;
state.cookieFileExpiring = health.cookies.fileExpiring || false;
}
if (net) state.lanMode = !!net.lan;
state.serverStatus = "connected";
// Library is free for everyone — load on every boot.
await loadHistory().catch(() => {});
if (isLicensed() && canUseSubscriptions()) {
await loadSubscriptions().catch(() => {});
try {
const added = await pollAutoQueue();
if (added) render();
} catch {}
startBgPoll();
}
render();
// Reconcile a returning BTCPay subscription checkout
// (/?billing=success) after the first paint — fire-and-forget so
// it never blocks boot.
handleBillingReturn();
// Expiry-reminder email "Renew" deep-link (/?renew=1).
handleRenewLink();
} catch {
state.serverStatus = "disconnected";
state.error = "Cannot connect to backend at localhost:3001.\nMake sure the server is running: cd server && npm install && npm start";
render();
}
}
// ── Heartbeat: ping server every 10s so it knows we're here ──
let heartbeatFailCount = 0;
async function sendHeartbeat() {
try {
const res = await fetch(`${API_BASE}/api/heartbeat`, { method: "POST" });
const data = await res.json();
heartbeatFailCount = 0;
const newStatus = data.sleeping ? "sleeping" : "connected";
if (state.serverStatus !== newStatus) {
state.serverStatus = newStatus;
updateStatusBadge();
}
} catch {
heartbeatFailCount++;
if (heartbeatFailCount >= 2 && state.serverStatus !== "disconnected") {
state.serverStatus = "disconnected";
updateStatusBadge();
}
}
}
// Update just the status badge without a full re-render (avoids player disruption)
function updateStatusBadge() {
const el = document.querySelector(".server-status");
if (el) {
el.outerHTML = renderServerStatus();
}
}
setInterval(sendHeartbeat, 10_000);
// Send heartbeat on page visibility change (coming back from another tab)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
sendHeartbeat();
// Connection-drop recovery: if we're returning to the tab AND
// the page is showing a "Connection dropped" error (which means
// the SSE stream died while backgrounded), re-check the server.
// Three outcomes:
// 1. A current job is still running → clear error, attach poller
// 2. A library entry was created very recently → the job
// finished while we were away; auto-load it so the user
// sees their summary instead of a red banner
// 3. Nothing in flight or recent → leave the error alone
if (state.error && /connection dropped/i.test(state.error)) {
(async () => {
try {
await loadCurrentJob();
if (state.currentJob) {
state.error = null;
startCurrentJobPoll();
render();
return;
}
// No current job. Check for a brand-new library entry —
// if one landed within the last 5 minutes it's almost
// certainly the job we just lost the stream for.
const res = await fetch(`${API_BASE}/api/history`);
const data = await res.json();
const sessions = Object.values(data.sessions || {});
sessions.sort(
(a, b) =>
new Date(b.createdAt).getTime() -
new Date(a.createdAt).getTime(),
);
const newest = sessions[0];
if (
newest &&
Date.now() - new Date(newest.createdAt).getTime() <
5 * 60 * 1000
) {
state.error = null;
await loadSession(newest.id);
await loadHistory().catch(() => {});
render();
}
} catch {
// Best-effort; if any of the recovery fetches fail we
// leave the error message intact and the user can retry.
}
})();
}
});
render();
// ── iOS Safari: prevent zoom-on-input-focus ──────────────────────
// Even with font-size: 16px on the input, iOS Safari occasionally
// still auto-zooms on focus — especially when the focused field
// overflows (e.g. a long pasted URL). The official escape hatch
// is to set maximum-scale=1 on the viewport meta, but doing so
// unconditionally also disables pinch-to-zoom on the rest of the
// page (accessibility-hostile). We toggle it ONLY while an input
// or textarea is focused: zoom is locked during typing, unlocked
// everywhere else. focusin/focusout bubble through any element
// so a single document-level listener covers every input on the
// page (URL input, settings forms, auth.html, etc.).
(function setupNoZoomOnInputFocus() {
const m = document.querySelector("meta[name=viewport]");
if (!m) return;
const normalContent = "width=device-width, initial-scale=1.0, viewport-fit=cover";
const noZoomContent = "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover";
m.setAttribute("content", normalContent);
document.addEventListener("focusin", (e) => {
const t = e.target;
if (!t || !t.tagName) return;
const tag = t.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
m.setAttribute("content", noZoomContent);
}
});
document.addEventListener("focusout", () => {
// Slight delay so the next focusin (chained focus moves)
// doesn't briefly flip to normalContent and back.
setTimeout(() => {
if (!document.activeElement ||
!["INPUT", "TEXTAREA", "SELECT"].includes(
document.activeElement.tagName,
)) {
m.setAttribute("content", normalContent);
}
}, 0);
});
})();
</script>
</body>
</html>