Files
recap/public/index.html
T
2026-04-09 15:03:31 -05:00

3681 lines
170 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Transcript Summarizer</title>
<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;
}
.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; }
.top-bar-input .url-input { padding: 7px 10px; font-size: 12px; }
.top-bar-input .submit-btn { padding: 7px 12px; font-size: 12px; }
}
/* 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; }
.top-bar {
flex-wrap: nowrap; gap: 6px;
}
/* Row 1: library btn + input + mobile menu */
.top-left-actions { order: 1; flex-shrink: 0; }
.top-left-actions .icon-btn { width: 36px; height: 36px; }
.top-bar-input {
order: 2; flex: 1; min-width: 0; margin: 0; gap: 4px;
}
.top-bar-input .url-input { padding: 8px 10px; font-size: 12px; }
.top-bar-input .submit-btn { padding: 8px 12px; font-size: 11px; white-space: nowrap; }
/* Hide the desktop icon row, show hamburger */
.top-actions { display: none !important; }
.mobile-menu-btn {
display: flex; order: 3; flex-shrink: 0;
width: 36px; height: 36px; border-radius: 9px; border: 1px solid #1e293b;
background: #111827; color: #64748b; font-size: 20px;
cursor: pointer; align-items: center; justify-content: center;
transition: all 0.15s; position: relative;
}
.mobile-menu-btn:hover { background: #1e293b; color: #94a3b8; }
.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); }
}
/* 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;
}
/* 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; }
/* 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; }
/* 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; }
/* 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; }
</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>
<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;
render();
// Wait for player to reinit, then seek
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;
}
}
}
function toggleVideoMinimize() {
state.videoMinimized = !state.videoMinimized;
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");
if (!audio) return;
audio.addEventListener("play", startPodcastSync);
audio.addEventListener("pause", stopPodcastSync);
audio.addEventListener("ended", stopPodcastSync);
}
// ── State ────────────────────────────────────────────────────────────────
const state = {
url: "",
apiKey: localStorage.getItem("yt-summarizer-gemini-key") || "",
hasServerKey: false, // will be set by health check
lanMode: null, // null = unknown, true = home, false = traveling
serverStatus: "connecting", // "connected" | "sleeping" | "disconnected" | "connecting"
model: "gemini-3.1-pro-preview",
showKey: false,
settingsOpen: false,
loading: false,
currentStep: 0,
status: "",
error: null,
videoId: null,
videoTitle: "",
chunks: [],
expandAll: false,
expandedChunks: new Set(),
logs: [],
logOpen: false,
// history
historyOpen: true,
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)
addingSubLoading: false,
subCheckLog: [], // persisted log from last subscription check
currentType: "youtube", // "youtube" or "podcast"
videoMinimized: false,
currentSessionId: 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,
};
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
// 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.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;
ytCurrentVideoId = null;
render();
}
// ── Process ──────────────────────────────────────────────────────────────
async function handleSubmit() {
const hasKey = state.apiKey.trim() || state.hasServerKey;
if (!state.url.trim() || !hasKey) return;
const url = state.url.trim();
state.url = "";
// If already processing, add to queue
if (state.loading) {
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
render();
return;
}
// Start processing immediately
await processUrl(url);
}
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;
return false;
}
function isSubscribeUrl(url) {
return isChannelUrl(url) || isPodcastUrl(url);
}
async function processUrl(url, opts = {}) {
// 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...";
state.settingsOpen = false;
state.expandedChunks = new Set();
// Accumulate logs across queue items — add separator for 2nd+ items
const title = opts.title || url;
if (state.logs.length > 0) {
state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
} else {
state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
}
render();
try {
const body = {
url,
apiKey: state.apiKey.trim() || "USE_SERVER_KEY",
model: state.model,
};
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();
throw new Error(err.error || `Server error: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
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() || "";
let eventType = "";
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);
}
}
}
} catch (err) {
state.error = err.message;
} finally {
state.loading = false;
state.currentStep = 0;
render();
// 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";
render();
await processUrl(next.url, {
type: next.type || "youtube",
title: next.title || "",
uploadDate: next.uploadDate || "",
episodeId: next.videoId || "",
});
// 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");
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") {
state.logs.push({ elapsed: data.elapsed, message: data.message, detail: data.detail });
renderLog();
} else if (event === "result") {
const videoChanged = state.videoId !== data.videoId;
state.videoId = data.videoId;
state.videoTitle = data.videoTitle || "";
state.chunks = data.chunks || [];
state.currentType = data.type || "youtube";
state.currentSessionId = data.historyId || null;
state.videoMinimized = false;
if (videoChanged) ytCurrentVideoId = null;
render();
// Refresh history list
loadHistory();
} else if (event === "error") {
state.error = data.message;
state.logs.push({ elapsed: "---", message: "ERROR: " + data.message, error: true });
render();
}
}
// ── Render ───────────────────────────────────────────────────────────────
function render() {
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);
app.innerHTML = `
<!-- Top bar: title + action icons -->
<div class="top-bar">
<div class="top-left-actions">
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="toggleHistory()" 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">
<input type="text" class="url-input"
placeholder="${state.loading ? "Paste another URL to queue it..." : "Paste a YouTube video, channel, or podcast RSS URL..."}"
value="${escHtml(state.url)}"
oninput="state.url=this.value; updateInputMode()"
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? addSubscriptionFromInput() : handleSubmit() }" />
<button class="submit-btn"
onclick="${isSubscribeUrl(state.url) ? "addSubscriptionFromInput()" : "handleSubmit()"}"
${!state.url.trim() || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey) ? "disabled" : ""}
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
</button>
</div>
<div class="top-actions">
${state.clipCollection.length > 0 ? `
<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>` : ""}
<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>
<button class="icon-btn" onclick="shutdownServer()" title="${state.serverStatus === 'connected' ? 'Connected — click to quit server' : state.serverStatus === 'disconnected' ? 'Server offline' : 'Connecting...'}" style="color:${state.serverStatus === 'connected' ? '#4ade80' : state.serverStatus === 'disconnected' ? '#f87171' : '#fbbf24'};">
<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="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
</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" : ""}">
${state.clipCollection.length > 0 ? `
<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>
` : ""}
<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 class="mobile-menu-sep"></div>
<button class="mobile-menu-item" onclick="closeMobileMenu(); shutdownServer()">
<span class="menu-icon" style="color:${state.serverStatus === 'connected' ? '#4ade80' : state.serverStatus === 'disconnected' ? '#f87171' : '#fbbf24'};">
<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="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
</span> ${state.serverStatus === 'connected' ? 'Server Connected' : state.serverStatus === 'disconnected' ? 'Server Offline' : 'Connecting...'}
<span class="status-dot" style="background:${state.serverStatus === 'connected' ? '#4ade80' : state.serverStatus === 'disconnected' ? '#f87171' : '#fbbf24'};"></span>
</button>
</div>
</div>
<!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""}
${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") ? renderLoadingSplit() : ""}
${state.loading && !state.videoId && state.currentType !== "podcast" ? renderLoading() : ""}
${state.chunks.length > 0 && !state.loading ? renderResults() : ""}
${state.logOpen ? renderLogDrawer() : ""}
${state.historyOpen ? renderHistorySidebar() : ""}
${state.clipPanelOpen ? renderClipPanel() : ""}
`;
// Re-init player if the yt-player div exists
if (state.videoId && ytReady && document.getElementById("yt-player")) {
setTimeout(() => initPlayer(state.videoId), 50);
}
// Init podcast audio player if present
if (document.getElementById("podcast-audio")) {
setTimeout(initPodcastPlayer, 50);
}
// Restore lastSeekTarget so toggle-play still works after render
lastSeekTarget = savedLastSeekTarget;
}
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 renderSettingsModal() {
return `
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
<div class="settings-modal">
<div class="settings-modal-header">
<h2>Settings</h2>
<button class="close-btn" onclick="toggleSettings()">&times;</button>
</div>
<div class="settings-modal-body">
<label class="field-label">API Key</label>
${state.hasServerKey ? `
<div class="ytdlp-status ytdlp-ok" style="margin-top:0;margin-bottom:8px">
<span>Server key configured</span>
</div>
` : ""}
<div class="key-row">
<input type="${state.showKey ? "text" : "password"}"
class="key-input" placeholder="${state.hasServerKey ? "Using server key (override here)" : "AIza..."}"
value="${escHtml(state.apiKey)}"
oninput="setApiKey(this.value)" />
<button class="key-toggle" onclick="toggleShowKey()">${state.showKey ? "Hide" : "Show"}</button>
</div>
<p class="key-hint">${state.hasServerKey
? "A shared key is set on the server. Enter one here to override it on this device only."
: "Saved locally in your browser. Or set GEMINI_API_KEY in the .env file to share across all devices."
}</p>
<label class="field-label">Analysis Model</label>
<div class="model-grid">
${MODELS.map(m => `
<button class="model-btn ${state.model === m ? "active" : ""}"
onclick="setModel('${m}')">${m.replace("-preview","")}</button>
`).join("")}
</div>
<p class="model-note">Transcription always uses Flash for speed. This model handles topic analysis.</p>
${renderYtdlpStatus()}
${renderCookieStatus()}
${renderSubscriptions()}
</div>
</div>
</div>
`;
}
async function shutdownServer() {
if (!confirm("Shut down the server? You'll need to relaunch the app to use it again.")) return;
try {
await fetch(API_BASE + "/api/shutdown", { method: "POST" });
document.getElementById("app").innerHTML = '<div style="text-align:center;padding:120px 20px;color:#64748b;font-size:16px"><p style="font-size:32px;margin-bottom:16px">Server stopped</p><p>You can close this tab. Double-click the app icon to start again.</p></div>';
} catch {}
}
// ── 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);
const hasKey = state.apiKey.trim() || state.hasServerKey;
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";
btn.disabled = !state.url.trim() || !hasKey;
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);
return `
<div style="display:flex; align-items:center; gap:8px; 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;">
Channel detected — subscribe to auto-process 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;" />
</div>
`;
}
async function addSubscriptionFromInput() {
const url = state.url.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 label = podcast ? url.split("/").pop() || "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
state.url = "";
state.addingSubSince = "";
state.addingSubLoading = false;
await loadSubscriptions();
render();
showToast(`Subscribed to ${label} — checking for ${podcast ? "episodes" : "videos"}...`, podcast ? "🎙" : "📡");
} 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 {
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 {}
}
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" : ""}
</div>
</div>
<div class="sub-actions">
<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; }
}
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 class="loading-status-bar" style="flex:0 0 auto; background:none; padding:0; border:none;">
<div class="spinner-sm"></div>
<span class="status-msg" style="font-size:11px;">${escHtml(state.status || "Processing...")}</span>
<div style="display:flex;gap:4px; margin-left:8px;">
${steps.map(s => `
<span style="width:8px;height:8px;border-radius:50%;
background:${state.currentStep > s.num ? "#4ade80" : state.currentStep === s.num ? "#60a5fa" : "#1e293b"};
transition:background 0.3s;" title="${s.label}"></span>
`).join("")}
</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>
</div>
<div class="results-right">
<div class="loading-status-bar">
<div class="spinner-sm"></div>
<span class="status-msg">${escHtml(state.status || "Processing...")}</span>
<div style="flex:1"></div>
<div style="display:flex;gap:4px">
${steps.map(s => `
<span style="width:8px;height:8px;border-radius:50%;
background:${state.currentStep > s.num ? "#4ade80" : state.currentStep === s.num ? "#60a5fa" : "#1e293b"};
transition:background 0.3s;" title="${s.label}"></span>
`).join("")}
</div>
</div>
<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() {
const steps = [
{ num: 1, label: "Download audio", icon: "\u2B07" },
{ num: 2, label: "Transcribe", icon: "\uD83C\uDFA4" },
{ num: 3, label: "Analyze topics", icon: "\uD83E\uDDE0" },
];
return `
<div class="loading">
<div class="pipeline">
${steps.map((s, i) => `
${i > 0 ? '<span class="step-arrow">\u2192</span>' : ""}
<div class="step ${state.currentStep === s.num ? "active" : ""} ${state.currentStep > s.num ? "done" : ""}">
<span class="step-icon">${state.currentStep > s.num ? "\u2713" : s.icon}</span>
${s.label}
</div>
`).join("")}
</div>
<div class="spinner"></div>
<p class="status-text">${escHtml(state.status || "Processing...")}</p>
</div>
`;
}
function renderResults() {
const totalEntries = state.chunks.reduce((sum, c) => sum + c.entries.length, 0);
const lastChunk = state.chunks[state.chunks.length - 1];
const lastEntry = lastChunk.entries[lastChunk.entries.length - 1];
const totalDuration = lastEntry ? lastEntry.offset : 0;
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;">
<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;">${state.chunks.length} topics &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
</div>
<button class="expand-btn" onclick="toggleVideoMinimize()" title="${state.videoMinimized ? "Show player" : "Hide player"}" style="margin-right:4px;">
${state.videoMinimized ? "Show Player" : "Hide Player"}
</button>
<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>
<div class="chunks-scroll" style="max-height: calc(100vh - ${state.videoMinimized ? "200" : "260"}px);">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
</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">${state.chunks.length} topics &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
` : ""}
</div>
<div class="results-right">
<div class="stats-bar">
<div class="stats">
<span><strong>${state.chunks.length}</strong> topics</span>
<span><strong>${totalEntries}</strong> segments</span>
<span><strong>${formatTime(totalDuration)}</strong> total</span>
</div>
<div style="display:flex; gap:6px;">
<button class="expand-btn" onclick="exportCurrentPDF()" title="Export PDF">
📄 Export PDF
</button>
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
</div>
<div class="chunks-scroll">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
</div>
</div>
</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 ? `<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>
<span class="transcript-text">${escHtml(entry.text)}</span>
${state.currentSessionId ? `<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");
}
// ── 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>`;
}
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 provide Premium ad-free audio and help with some restricted videos.<br>' +
'Export cookies with "Get cookies.txt LOCALLY" extension in your browser.</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>
<button class="close-btn" onclick="toggleLog()">&times;</button>
</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>`
: state.logs.map(l => l.separator ? `
<div class="log-entry" style="border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px;">
<span class="log-msg">${escHtml(l.message)}</span>
</div>` : `
<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>
`).join("")}
</div>
</div>
`;
}
function renderLog() {
const el = document.getElementById("log-body");
if (el && state.logOpen) {
const last = state.logs[state.logs.length - 1];
if (last) {
const entry = document.createElement("div");
if (last.separator) {
entry.className = "log-entry";
entry.style.cssText = "border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px;";
entry.innerHTML = `<span class="log-msg">${escHtml(last.message)}</span>`;
} else {
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>`;
}
el.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(); }
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) loadSubscriptions();
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: [] };
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();
state.currentSessionId = id;
state.videoId = data.videoId;
state.videoTitle = data.title || "";
state.url = data.url || "";
state.chunks = data.chunks || [];
state.logs = data.logs || [];
state.currentType = data.type || "youtube";
state.expandedChunks = new Set();
state.expandAll = false;
state.loading = false;
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) {
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;
} 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) {
if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id);
else state.collapsedFolders.add(id);
render();
}
// Drag & drop with insertion indicator
let dragDropTarget = null; // { sessionId, position: "above"|"below", folderId }
function onDragStart(ev, sessionId) {
state.draggingId = sessionId;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", sessionId);
// Use timeout so the dragging class applies after the drag image is captured
setTimeout(() => ev.target.classList.add("dragging"), 0);
}
function onDragEnd(ev) {
state.draggingId = null;
dragDropTarget = null;
document.querySelectorAll(".dragging, .drop-above, .drop-below, .drag-over").forEach(
el => el.classList.remove("dragging", "drop-above", "drop-below", "drag-over")
);
}
function onItemDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const item = ev.currentTarget;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position = ev.clientY < midY ? "above" : "below";
// Clear all indicators
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
// Set indicator on this item
item.classList.add(position === "above" ? "drop-above" : "drop-below");
// Track target for drop
const targetId = item.dataset.sessionId;
const folderId = item.dataset.folderId || null;
dragDropTarget = { targetId, position, folderId };
}
function onItemDragLeave(ev) {
const item = ev.currentTarget;
// Only remove if actually leaving (not entering a child)
if (!item.contains(ev.relatedTarget)) {
item.classList.remove("drop-above", "drop-below");
}
}
async function onItemDrop(ev) {
ev.preventDefault();
ev.stopPropagation();
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
const sessionId = state.draggingId;
if (!sessionId || !dragDropTarget) return;
if (sessionId === dragDropTarget.targetId) return; // Dropped on self
const { targetId, position, folderId } = dragDropTarget;
// Determine the target list and insertion index
let targetList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
targetList = folder ? folder.items : null;
} else {
targetList = state.historyMeta.uncategorized;
}
if (!targetList) return;
// Find where the target is and compute insertion index
const targetIdx = targetList.indexOf(targetId);
if (targetIdx === -1) return;
const insertIdx = position === "below" ? targetIdx + 1 : targetIdx;
// Remove from all lists first
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
// Re-resolve target list after removal (indices may have shifted)
let finalList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
finalList = folder ? folder.items : null;
} else {
finalList = state.historyMeta.uncategorized;
}
if (!finalList) return;
// Compute the final index (target may have shifted if dragged item was before it)
const finalTargetIdx = finalList.indexOf(targetId);
const finalInsertIdx = position === "below" ? finalTargetIdx + 1 : finalTargetIdx;
finalList.splice(finalInsertIdx, 0, sessionId);
state.draggingId = null;
dragDropTarget = null;
render();
// Persist to server
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId, index: finalInsertIdx }),
});
} catch {}
}
// Folder header drop (move into a folder, appended at end)
async function onDropToFolder(ev, folderId) {
ev.preventDefault();
ev.currentTarget.classList.remove("drag-over");
const sessionId = state.draggingId;
if (!sessionId) return;
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
if (folder) folder.items.push(sessionId);
} else {
state.historyMeta.uncategorized.push(sessionId);
}
state.draggingId = null;
dragDropTarget = null;
render();
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId }),
});
} catch {}
}
function onListDragOver(ev) { ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; }
async function onDropToUncategorized(ev) { await onDropToFolder(ev, null); }
function formatUploadDate(yyyymmdd) {
// yt-dlp returns YYYYMMDD, e.g. "20260207"
if (!yyyymmdd || yyyymmdd.length !== 8) return "";
const y = yyyymmdd.slice(0, 4);
const m = parseInt(yyyymmdd.slice(4, 6), 10);
const d = parseInt(yyyymmdd.slice(6, 8), 10);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[m - 1]} ${d}, ${y}`;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return mins + "m ago";
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + "h ago";
const days = Math.floor(hrs / 24);
if (days < 7) return days + "d ago";
return new Date(dateStr).toLocaleDateString();
}
function renderHistoryItem(id, folderId) {
const h = state.historySessions[id];
if (!h) return "";
const isEditing = state.editingSessionTitle === h.id;
const folderAttr = folderId ? `data-folder-id="${folderId}"` : "";
return `
<div class="history-item ${state.videoId === h.videoId ? "active" : ""}"
draggable="${isEditing ? "false" : "true"}"
data-session-id="${h.id}" ${folderAttr}
ondragstart="onDragStart(event, '${h.id}')"
ondragend="onDragEnd(event)"
ondragover="onItemDragOver(event)"
ondragleave="onItemDragLeave(event)"
ondrop="onItemDrop(event)"
onclick="${isEditing ? "" : "loadSession('" + h.id + "')"}">
<div class="history-thumb">
${h.type === "podcast"
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#1e293b;border-radius:6px;font-size:20px;">🎙</div>`
: `<img src="https://img.youtube.com/vi/${h.videoId}/default.jpg" alt="" loading="lazy" />`}
</div>
<div class="history-info">
${isEditing
? `<input class="history-title-input" id="session-title-edit-${h.id}"
value="${escHtml(h.title)}"
onclick="event.stopPropagation()"
onblur="renameSession('${h.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingSessionTitle=null;render()}" />`
: `<div class="history-title" title="${escHtml(h.title)} (double-click to rename)"
ondblclick="event.stopPropagation(); startEditSessionTitle('${h.id}')">${escHtml(h.title)}</div>`
}
<div class="history-meta">${h.uploadDate ? formatUploadDate(h.uploadDate) : timeAgo(h.createdAt)} &middot; ${h.topicCount} topics</div>
</div>
<button class="history-action-small" onclick="event.stopPropagation(); exportSessionPDF('${h.id}')" title="Export PDF">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
</button>
<button class="history-delete" onclick="deleteSession('${h.id}', event)" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
function renderHistorySidebar() {
const totalSessions = Object.keys(state.historySessions).length;
const { folders, uncategorized } = state.historyMeta;
return `
<div class="history-sidebar-overlay" onclick="toggleHistory()"></div>
<div class="history-sidebar ${historyAnimateIn ? "animate-in" : ""}">
<div class="history-header">
<h2>Library</h2>
<div class="history-actions">
<button class="history-action-btn" onclick="createFolder()" title="New Folder">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
</button>
<button class="close-btn" onclick="toggleHistory()" style="width:30px;height:30px;font-size:16px">&times;</button>
</div>
</div>
<div class="history-list"
ondragover="onListDragOver(event)"
ondrop="onDropToUncategorized(event)">
${totalSessions === 0
? `<div class="history-empty">No sessions yet. Summarize a video to see it here.</div>`
: `
${folders.map(folder => {
const isCollapsed = state.collapsedFolders.has(folder.id);
const isEditing = state.editingFolder === folder.id;
return `
<div class="history-folder">
<div class="history-folder-header" onclick="toggleFolder('${folder.id}')"
ondragover="event.preventDefault(); event.dataTransfer.dropEffect='move'; event.currentTarget.classList.add('drag-over')"
ondragleave="event.currentTarget.classList.remove('drag-over')"
ondrop="event.currentTarget.classList.remove('drag-over'); onDropToFolder(event, '${folder.id}')">
<span class="folder-arrow ${isCollapsed ? "" : "open"}">\u25B6</span>
<span class="folder-icon">\uD83D\uDCC1</span>
${isEditing
? `<input class="folder-name-input" id="folder-edit-${folder.id}"
value="${escHtml(folder.name)}"
onclick="event.stopPropagation()"
onblur="renameFolder('${folder.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingFolder=null;render()}" />`
: `<span class="folder-name">${escHtml(folder.name)}</span>`
}
<span class="folder-count">${folder.items.length}</span>
<span class="folder-actions">
<button class="folder-action" onclick="event.stopPropagation(); state.editingFolder='${folder.id}'; render(); setTimeout(()=>{const el=document.getElementById('folder-edit-${folder.id}');if(el){el.focus();el.select()}},50)" title="Rename">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button class="folder-action danger" onclick="deleteFolder('${folder.id}', event)" title="Delete folder">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</span>
</div>
<div class="folder-items ${isCollapsed ? "collapsed" : ""}">
${folder.items.map(id => renderHistoryItem(id, folder.id)).join("")}
</div>
</div>
`;
}).join("")}
${uncategorized.length > 0 && folders.length > 0 ? `<div class="history-section-label">Unsorted</div>` : ""}
${uncategorized.map(id => renderHistoryItem(id)).join("")}
`
}
</div>
</div>
`;
}
function toggleShowKey() { state.showKey = !state.showKey; render(); }
function setApiKey(v) {
state.apiKey = v;
localStorage.setItem("yt-summarizer-gemini-key", v);
}
function setModel(m) { state.model = m; render(); }
function toggleExpandAll() {
state.expandAll = !state.expandAll;
if (!state.expandAll) state.expandedChunks.clear();
render();
}
function toggleChunk(i) {
const wasExpanded = state.expandedChunks.has(i);
// Collapse all others (accordion behavior)
state.expandedChunks.clear();
state.expandAll = false;
// Toggle the clicked one
if (!wasExpanded) state.expandedChunks.add(i);
render();
// Scroll the expanded chunk to the top of the scroll area
if (!wasExpanded) {
setTimeout(() => {
const chunkEl = document.getElementById("chunk-" + i);
const scrollEl = document.querySelector(".chunks-scroll");
if (chunkEl && scrollEl) {
chunkEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 60);
}
}
function toggleClipPanel() {
state.clipPanelOpen = !state.clipPanelOpen;
render();
}
function renderClipPanel() {
const clips = state.clipCollection;
const grouped = {};
clips.forEach((clip, idx) => {
if (!grouped[clip.sessionId]) grouped[clip.sessionId] = [];
grouped[clip.sessionId].push({ ...clip, _idx: idx });
});
let clipListHtml = "";
if (clips.length === 0) {
clipListHtml = '<div style="text-align:center; padding:32px 16px; color:#475569; font-size:13px;">' +
'<p style="margin-bottom:8px;">No clips collected yet.</p>' +
'<p style="font-size:12px;">Click 📎 on any topic or transcript line to add it here.<br>' +
'You can collect clips from multiple videos to build a curated export.</p></div>';
} else {
for (const [sessionId, sessionClips] of Object.entries(grouped)) {
const session = state.historySessions[sessionId];
const sessionTitle = session ? escHtml(session.title) : "Unknown Video";
clipListHtml += '<div style="margin-bottom:16px;">';
clipListHtml += '<div style="font-size:13px; font-weight:600; color:#e2e8f0; margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid #1e293b;">' + sessionTitle + '</div>';
for (const clip of sessionClips) {
const label = clip.entryIndex !== null
? "Topic " + (clip.chunkIndex + 1) + ", line " + (clip.entryIndex + 1)
: "Topic " + (clip.chunkIndex + 1) + " (full)";
const noteHtml = clip.note
? '<div style="font-size:11px; color:#818cf8; font-style:italic; margin-top:2px; padding-left:2px;">' +
'💬 ' + escHtml(clip.note) + '</div>'
: '';
clipListHtml += '<div style="padding:6px 8px; border-radius:6px; margin-bottom:4px;" onmouseover="this.style.background=\'rgba(129,140,248,0.06)\'" onmouseout="this.style.background=\'none\'">' +
'<div style="display:flex; align-items:center; gap:8px;">' +
'<span style="flex:1; font-size:12px; color:#94a3b8;">' + escHtml(label) + '</span>' +
'<button style="border:none; background:none; color:#64748b; cursor:pointer; font-size:11px; padding:2px 6px; border-radius:4px;" onclick="editClipNote(' + clip._idx + ')" title="Edit note">✏️</button>' +
'<button style="border:none; background:none; color:#475569; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px;" onclick="removeFromClipCollection(' + clip._idx + ')" title="Remove">&times;</button>' +
'</div>' +
noteHtml +
'</div>';
}
clipListHtml += '</div>';
}
}
return `
<div class="settings-overlay" onclick="toggleClipPanel()">
<div class="settings-modal" onclick="event.stopPropagation()" style="max-width: 560px;">
<div class="settings-modal-header">
<h2>📎 Clip Collection (${clips.length})</h2>
<div style="display:flex; gap:8px;">
${clips.length > 0 ? `
<button class="expand-btn" onclick="exportClipCollectionPDF()" style="font-size:11px; padding:5px 12px;">Export PDF</button>
<button class="expand-btn" onclick="clearClipCollection()" style="font-size:11px; padding:5px 12px; color:#f87171;">Clear All</button>
` : ""}
<button class="close-btn" onclick="toggleClipPanel()">&times;</button>
</div>
</div>
<div class="settings-modal-body" style="max-height: 60vh; overflow-y: auto;">
${clipListHtml}
</div>
</div>
</div>
`;
}
// ── PDF Export & Clip Collection ────────────────────────────────────────
async function exportSessionPDF(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
if (!data.chunks || data.chunks.length === 0) { showToast("No data to export", "⚠", 3000); return; }
buildPDF(data.title || "Untitled", data.videoId, data.chunks, data.type || "youtube");
} 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);
}
// ── 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) {
if (!window.jspdf) {
showToast("PDF library not loaded yet — please try again in a moment", "⚠", 4000);
return;
}
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);
// ── 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;
// Timestamp
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", margin + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
// Text after timestamp
const textLines = doc.splitTextToSize(entry.text, maxW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, margin + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, margin + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, maxW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, margin + 2, 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) {
// Check if already in collection
const exists = state.clipCollection.find(c =>
c.sessionId === sessionId && c.chunkIndex === chunkIndex &&
(entryIndex === undefined ? c.entryIndex === null : c.entryIndex === entryIndex)
);
if (exists) {
showToast("Already in clip collection", "", 2000);
return;
}
// Show notes prompt modal
showClipNotePrompt(sessionId, chunkIndex, entryIndex !== undefined ? entryIndex : null);
}
function showClipNotePrompt(sessionId, chunkIndex, entryIndex) {
// Create overlay
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>📎 Add Clip</h2>' +
'<button class="close-btn" id="clip-note-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<label class="field-label" style="margin-top:0">Note (optional)</label>' +
'<textarea id="clip-note-input" rows="3" placeholder="Why is this interesting? Add context for when you share it..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;"></textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-note-skip" class="expand-btn" style="font-size:12px; padding:8px 16px;">Skip</button>' +
'<button id="clip-note-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Add Clip</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-note-input");
setTimeout(() => input.focus(), 50);
function finish(note) {
state.clipCollection.push({
sessionId,
chunkIndex,
entryIndex,
note: (note || "").trim() || null,
});
saveClipCollection();
overlay.remove();
showToast("Clip added (" + state.clipCollection.length + " total)", "📎");
render();
}
function cancel() { overlay.remove(); }
document.getElementById("clip-note-save").onclick = () => finish(input.value);
document.getElementById("clip-note-skip").onclick = () => finish("");
document.getElementById("clip-note-cancel").onclick = cancel;
overlay.onclick = cancel;
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finish(input.value); } };
}
function editClipNote(index) {
const clip = state.clipCollection[index];
if (!clip) return;
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>✏️ Edit Note</h2>' +
'<button class="close-btn" id="clip-edit-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<textarea id="clip-edit-input" rows="3" placeholder="Add your thoughts about this clip..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;">' +
escHtml(clip.note || "") + '</textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-edit-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-edit-input");
setTimeout(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 50);
function save() {
state.clipCollection[index].note = input.value.trim() || null;
saveClipCollection();
overlay.remove();
render();
}
document.getElementById("clip-edit-save").onclick = save;
document.getElementById("clip-edit-cancel").onclick = () => overlay.remove();
overlay.onclick = () => overlay.remove();
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } };
}
function removeFromClipCollection(index) {
state.clipCollection.splice(index, 1);
saveClipCollection();
render();
}
function clearClipCollection() {
state.clipCollection = [];
state.clipPanelOpen = false;
saveClipCollection();
render();
showToast("Clip collection cleared", "🗑");
}
function saveClipCollection() {
localStorage.setItem("yt-summarizer-clips", JSON.stringify(state.clipCollection));
}
function loadClipCollection() {
try {
const saved = localStorage.getItem("yt-summarizer-clips");
if (saved) state.clipCollection = JSON.parse(saved);
} catch {}
}
async function exportClipCollectionPDF() {
if (state.clipCollection.length === 0) { showToast("No clips collected", "⚠", 3000); return; }
if (!window.jspdf) { showToast("PDF library not loaded yet — please try again", "⚠", 4000); return; }
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(22); doc.setTextColor(...C.title);
doc.text("Curated Clips", margin, p.getY()); p.addY(9);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(state.clipCollection.length + " clips | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(10);
// Fetch all needed sessions
const sessionIds = [...new Set(state.clipCollection.map(c => c.sessionId))];
const sessionData = {};
for (const sid of sessionIds) {
try {
const res = await fetch(API_BASE + "/api/history/" + sid);
sessionData[sid] = await res.json();
} catch { sessionData[sid] = null; }
}
// ── Render clips ──
let currentSessionId = null;
let clipNum = 0;
for (const clip of state.clipCollection) {
const session = sessionData[clip.sessionId];
if (!session) continue;
const chunk = session.chunks?.[clip.chunkIndex];
if (!chunk) continue;
clipNum++;
// Session header if new session
if (clip.sessionId !== currentSessionId) {
checkPage(18);
if (currentSessionId !== null) { p.addY(6); }
// Session divider with title
doc.setDrawColor(...C.divider); doc.setLineWidth(0.3);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text("SOURCE", margin, p.getY()); p.addY(4);
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...C.title);
const stitleLines = doc.splitTextToSize(session.title || "Untitled", maxW);
stitleLines.forEach(line => { checkPage(7); doc.text(line, margin, p.getY()); p.addY(6); });
p.addY(4);
currentSessionId = clip.sessionId;
}
// ── User note (shaded box with left accent bar) ──
if (clip.note) {
drawNoteBox(clip.note, margin, maxW);
}
// ── Clip content (indented under the note) ──
const indent = clip.note ? 4 : 0; // indent content when there's a note
const contentX = margin + indent;
const contentW = maxW - indent;
checkPage(14);
const isYt = (session.type || "youtube") === "youtube";
const vid = session.videoId;
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + startSec : null;
// Topic label + time
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("CLIP " + clipNum, contentX, p.getY());
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, contentX + 18, p.getY());
p.addY(5);
// Topic title with link
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...C.heading);
const ctLines = doc.splitTextToSize(chunk.title, contentW);
ctLines.forEach((line, li) => {
checkPage(5.5);
if (li === 0 && ytUrl) {
drawLink(line, contentX, p.getY(), ytUrl);
} else {
doc.text(line, contentX, p.getY());
}
p.addY(5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, contentW);
sumLines.forEach(line => { checkPage(4.5); doc.text(line, contentX, p.getY()); p.addY(4); });
p.addY(2);
// Transcript entries
const entries = clip.entryIndex !== null ? [chunk.entries[clip.entryIndex]].filter(Boolean) : chunk.entries;
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
entries.forEach(entry => {
checkPage(4);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + sec : null;
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", contentX + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const textLines = doc.splitTextToSize(entry.text, contentW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, contentX + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, contentX + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, contentW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, contentX + 2, p.getY()); p.addY(3.6); });
}
});
p.addY(6);
// Light separator between clips
doc.setDrawColor(...C.divider); doc.setLineWidth(0.1);
const sepX1 = margin + 20; const sepX2 = pw - margin - 20;
doc.line(sepX1, p.getY(), sepX2, p.getY()); p.addY(6);
}
addFooter();
doc.save("curated-clips.pdf");
showToast("Clips PDF exported", "📄");
} catch (err) {
console.error("Clip PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
function escHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
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();
// Fetch health + network mode in parallel
Promise.all([
fetch(`${API_BASE}/api/health`).then(r => r.json()),
fetch(`${API_BASE}/api/network-mode`).then(r => r.json()).catch(() => null),
loadHistory(),
]).then(([health, net]) => {
state.hasServerKey = !!health.hasServerKey;
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;
}
// Cookie status
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";
render();
// Load subscriptions, do initial auto-queue check, start background poll
loadSubscriptions().then(async () => {
const added = await pollAutoQueue();
if (added) render();
startBgPoll();
});
}).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") {
sendHeartbeat();
}
});
render();
</script>
</body>
</html>