b4fa5d7be8
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.
12672 lines
595 KiB
HTML
12672 lines
595 KiB
HTML
<!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 2–3 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 & 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 →</button>
|
||
<button class="activation-link"
|
||
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
|
||
onclick="dismissActivation()">
|
||
Skip — use free mode
|
||
</button>
|
||
</div>
|
||
<div class="activation-meta">
|
||
Product: <strong>${escHtml(lic.productSlug || "recap")}</strong>
|
||
${lic.keysatBaseUrl ? ` · 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;"> · ${escHtml(what)}</span>
|
||
<span style="color:#94a3b8;"> · <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 · bring your own API key · upgrade for auto-queue, clips, and relay credits";
|
||
} else if (!isProTier()) {
|
||
label = "Paid license";
|
||
descr = "your license is missing some paid features — 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>
|
||
· ${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">×</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 →
|
||
</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 →
|
||
</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()">← 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 →
|
||
</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 & 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 & 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 “walking mode” playback",
|
||
"Speaker names & 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">×</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 & 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">×</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">×</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 & 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// 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">×</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 →
|
||
</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">×</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 →
|
||
</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 →
|
||
</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()">← 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 & 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;">· ${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 →
|
||
</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>· Tier: <strong style="color:#e2e8f0;">${tierLabel}</strong></span>
|
||
<span>· ${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) · 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 “✓ Server-configured” 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 — leave blank to use it</div>`;
|
||
} else if (!localUrl && urlOnServer) {
|
||
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action — 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 — 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(" · ")} · <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;">· ${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)} · Pro feature</div>
|
||
<div class="pro-desc">${escHtml(description)}</div>
|
||
<button class="pro-cta" onclick="openBuyModal()" style="cursor:pointer;border:none;">Upgrade to Pro →</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()">×</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()">×</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} →
|
||
</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 · ${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 · ${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×</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}×`;
|
||
}
|
||
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()">×</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;">· 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">×</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)} · ${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">×</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">×</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()">×</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">×</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">×</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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
|
||
// ── 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>
|