Save in-progress keysat integration and StartOS 0.4 work
Snapshot of the working tree before cleanup. Captures: - Keysat licensing: server/license.js, /api/license/* endpoints in server/index.js, activation modal in public/index.html, embedded Ed25519 issuer key (assets/issuer.pub). - StartOS 0.4 expansion: setApiKey action, version files v0.1.1 through v0.1.15, file-models/config.json.ts, manifest updates. - Self-hosted registry server (startos-registry/). - Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored yt-dlp binary), .gitignore, .deploy.env.example. - Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) — retained here so they remain recoverable when removed in the follow-up cleanup commit.
This commit is contained in:
+524
-50
@@ -858,6 +858,91 @@
|
||||
}
|
||||
.loading-status-bar .status-msg { font-size: 12px; color: #94a3b8; font-weight: 500; }
|
||||
.server-status.sleeping .status-dot { animation: pulse-dot 2s ease-in-out infinite; }
|
||||
|
||||
/* ── Keysat activation + license UI ───────────────────────────── */
|
||||
.activation-screen {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: radial-gradient(ellipse at top, #1e293b 0%, #0a0e1a 60%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; overflow-y: auto;
|
||||
}
|
||||
.activation-card {
|
||||
width: 100%; max-width: 480px;
|
||||
background: #0f172a; border: 1px solid #1e293b; border-radius: 14px;
|
||||
padding: 32px 28px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.activation-card h1 { font-size: 22px; font-weight: 700; color: #e2e8f0; margin-bottom: 6px; }
|
||||
.activation-card .activation-sub { font-size: 13px; color: #94a3b8; line-height: 1.5; margin-bottom: 22px; }
|
||||
.activation-card .activation-label { display: block; font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
||||
.activation-card textarea.activation-key {
|
||||
width: 100%; min-height: 88px; padding: 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px;
|
||||
background: #020617; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px;
|
||||
outline: none; resize: vertical; line-height: 1.45;
|
||||
}
|
||||
.activation-card textarea.activation-key:focus { border-color: #6366f1; }
|
||||
.activation-card .activation-error {
|
||||
margin-top: 10px; padding: 10px 12px;
|
||||
background: rgba(220, 38, 38, 0.1); border: 1px solid rgba(220, 38, 38, 0.4);
|
||||
border-radius: 8px; color: #fca5a5; font-size: 12px; line-height: 1.4;
|
||||
}
|
||||
.activation-card .activation-actions {
|
||||
display: flex; gap: 10px; margin-top: 16px; align-items: center;
|
||||
}
|
||||
.activation-card .activation-btn {
|
||||
flex: 1; padding: 11px 18px; font-size: 13px; font-weight: 600;
|
||||
background: #6366f1; color: #fff; border: none; border-radius: 8px; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.activation-card .activation-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.activation-card .activation-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.activation-card .activation-link {
|
||||
font-size: 12px; color: #818cf8; text-decoration: none; padding: 8px 4px;
|
||||
}
|
||||
.activation-card .activation-link:hover { color: #a5b4fc; text-decoration: underline; }
|
||||
.activation-card .activation-meta {
|
||||
margin-top: 24px; padding-top: 18px; border-top: 1px solid #1e293b;
|
||||
font-size: 11px; color: #64748b; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* In-settings license block + Pro upsell tiles */
|
||||
.license-block {
|
||||
padding: 14px; border: 1px solid #334155; border-radius: 10px;
|
||||
background: rgba(99, 102, 241, 0.06); margin-bottom: 14px;
|
||||
}
|
||||
.license-block .lic-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.license-block .lic-tier {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
padding: 3px 8px; border-radius: 999px;
|
||||
}
|
||||
.license-block .lic-tier.core { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.4); }
|
||||
.license-block .lic-tier.pro { background: rgba(168, 85, 247, 0.15); color: #d8b4fe; border: 1px solid rgba(168, 85, 247, 0.4); }
|
||||
.license-block .lic-tier.unlicensed { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border: 1px solid #334155; }
|
||||
.license-block .lic-meta { font-size: 11px; color: #64748b; margin-top: 8px; line-height: 1.5; }
|
||||
.license-block .lic-meta .lic-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #94a3b8; }
|
||||
.license-block .lic-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.license-block .lic-btn {
|
||||
font-size: 11px; font-weight: 600; padding: 6px 12px; border-radius: 6px;
|
||||
border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer;
|
||||
text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.license-block .lic-btn:hover { background: #334155; color: #e2e8f0; }
|
||||
.license-block .lic-btn.danger { color: #fca5a5; border-color: rgba(220, 38, 38, 0.4); }
|
||||
.license-block .lic-btn.danger:hover { background: rgba(220, 38, 38, 0.1); }
|
||||
|
||||
.pro-upsell {
|
||||
padding: 14px; border: 1px dashed #334155; border-radius: 10px;
|
||||
background: rgba(168, 85, 247, 0.05); margin: 8px 0;
|
||||
}
|
||||
.pro-upsell .pro-title { font-size: 13px; font-weight: 700; color: #d8b4fe; display: flex; align-items: center; gap: 6px; }
|
||||
.pro-upsell .pro-desc { font-size: 11.5px; color: #94a3b8; margin-top: 6px; line-height: 1.5; }
|
||||
.pro-upsell .pro-cta {
|
||||
display: inline-block; margin-top: 10px;
|
||||
padding: 6px 12px; font-size: 11px; font-weight: 600;
|
||||
background: #a855f7; color: #fff; border-radius: 6px; text-decoration: none;
|
||||
}
|
||||
.pro-upsell .pro-cta:hover { background: #9333ea; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="history-open">
|
||||
@@ -1166,6 +1251,7 @@
|
||||
subsLoaded: false,
|
||||
addingSubUrl: "",
|
||||
addingSubSince: "", // optional cutoff date (YYYY-MM-DD)
|
||||
addingSubAuto: false, // auto-process new videos (skip queue approval)
|
||||
addingSubLoading: false,
|
||||
subCheckLog: [], // persisted log from last subscription check
|
||||
currentType: "youtube", // "youtube" or "podcast"
|
||||
@@ -1180,6 +1266,21 @@
|
||||
cookieMethod: "none",
|
||||
cookieFileAgeDays: null,
|
||||
cookieFileExpiring: false,
|
||||
// license (Keysat)
|
||||
license: {
|
||||
loaded: false,
|
||||
state: "loading", // 'loading' | 'licensed' | 'unlicensed' | 'invalid'
|
||||
reason: null,
|
||||
licenseId: null,
|
||||
entitlements: [],
|
||||
expiresAt: null,
|
||||
isTrial: false,
|
||||
productSlug: "youtube-summarizer",
|
||||
keysatBaseUrl: "",
|
||||
},
|
||||
licenseActivating: false,
|
||||
licenseActivationError: null,
|
||||
licenseActivationKey: "",
|
||||
};
|
||||
|
||||
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
|
||||
@@ -1451,8 +1552,8 @@
|
||||
state.videoMinimized = false;
|
||||
if (videoChanged) ytCurrentVideoId = null;
|
||||
render();
|
||||
// Refresh history list
|
||||
loadHistory();
|
||||
// Refresh history list and re-render when loaded
|
||||
loadHistory().then(() => render());
|
||||
} else if (event === "error") {
|
||||
state.error = data.message;
|
||||
state.logs.push({ elapsed: "---", message: "ERROR: " + data.message, error: true });
|
||||
@@ -1462,7 +1563,72 @@
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
function renderActivationScreen() {
|
||||
const lic = state.license;
|
||||
const reasonHints = {
|
||||
product_mismatch: "This license is for a different product.",
|
||||
revoked: "This license has been revoked.",
|
||||
expired: "This license has expired.",
|
||||
bad_signature: "This license appears tampered.",
|
||||
not_found: "This license key was not recognized.",
|
||||
license_status_unreachable: "Couldn't reach the licensing server. Check that the backend is running.",
|
||||
};
|
||||
const reasonHint = lic.reason ? (reasonHints[lic.reason] || lic.reason) : null;
|
||||
const loading = lic.state === "loading" || !lic.loaded;
|
||||
const buyUrl = upgradeToProUrl();
|
||||
return `
|
||||
<div class="activation-screen">
|
||||
<div class="activation-card">
|
||||
<h1>Activate YouTube Summarizer</h1>
|
||||
<p class="activation-sub">
|
||||
${loading
|
||||
? "Checking license…"
|
||||
: "Paste your Keysat license key to unlock this app. Buy a key from the seller, then come back here."
|
||||
}
|
||||
</p>
|
||||
${loading ? "" : `
|
||||
<label class="activation-label">License key</label>
|
||||
<textarea class="activation-key" placeholder="LIC1-..." spellcheck="false"
|
||||
oninput="state.licenseActivationKey=this.value; document.getElementById('activate-btn').disabled = !this.value.trim() || state.licenseActivating">${escHtml(state.licenseActivationKey)}</textarea>
|
||||
${state.licenseActivationError ? `<div class="activation-error">${escHtml(state.licenseActivationError)}</div>` : ""}
|
||||
${reasonHint && !state.licenseActivationError ? `<div class="activation-error">${escHtml(reasonHint)}</div>` : ""}
|
||||
<div class="activation-actions">
|
||||
<button id="activate-btn" class="activation-btn"
|
||||
${(!state.licenseActivationKey.trim() || state.licenseActivating) ? "disabled" : ""}
|
||||
onclick="activateLicense()">
|
||||
${state.licenseActivating ? "Activating…" : "Activate"}
|
||||
</button>
|
||||
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key →</a>
|
||||
</div>
|
||||
<div class="activation-meta">
|
||||
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
|
||||
${lic.keysatBaseUrl ? ` · Issuer: <strong>${escHtml(lic.keysatBaseUrl.replace(/^https?:\/\//, ""))}</strong>` : ""}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
// Hard-gate the entire UI behind a valid license (matches the server's
|
||||
// activation-screen flavor). Once licensed + has core, fall through to
|
||||
// the normal app render below.
|
||||
if (state.license.loaded && !isLicensed()) {
|
||||
const app = document.getElementById("app");
|
||||
app.className = "container";
|
||||
app.innerHTML = renderActivationScreen();
|
||||
return;
|
||||
}
|
||||
// Initial paint while license-status is still in-flight: show the
|
||||
// activation card in its loading skeleton state rather than a flash of
|
||||
// the underlying app.
|
||||
if (!state.license.loaded) {
|
||||
const app = document.getElementById("app");
|
||||
app.className = "container";
|
||||
app.innerHTML = renderActivationScreen();
|
||||
return;
|
||||
}
|
||||
savePlayerState();
|
||||
const app = document.getElementById("app");
|
||||
const hasResults = state.chunks.length > 0 && !state.loading;
|
||||
@@ -1470,6 +1636,9 @@
|
||||
app.className = showSplit ? "container has-results" : "container";
|
||||
// Toggle body class for sidebar layout shift
|
||||
document.body.classList.toggle("history-open", state.historyOpen);
|
||||
// Preserve library sidebar scroll position across full re-renders
|
||||
const __prevHistoryListEl = document.querySelector(".history-list");
|
||||
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
|
||||
app.innerHTML = `
|
||||
<!-- Top bar: title + action icons -->
|
||||
<div class="top-bar">
|
||||
@@ -1494,7 +1663,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
${state.clipCollection.length > 0 ? `
|
||||
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
|
||||
<button class="icon-btn" onclick="toggleClipPanel()" title="Clip Collection (${state.clipCollection.length})" style="position:relative;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
||||
@@ -1520,11 +1689,6 @@
|
||||
</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)">
|
||||
@@ -1535,7 +1699,7 @@
|
||||
</button>
|
||||
<div class="mobile-menu-overlay ${state.mobileMenuOpen ? "open" : ""}" onclick="closeMobileMenu()"></div>
|
||||
<div class="mobile-menu-dropdown ${state.mobileMenuOpen ? "open" : ""}">
|
||||
${state.clipCollection.length > 0 ? `
|
||||
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
|
||||
<button class="mobile-menu-item" onclick="closeMobileMenu(); toggleClipPanel()">
|
||||
<span class="menu-icon">📎</span> Clips
|
||||
<span class="menu-badge">${state.clipCollection.length}</span>
|
||||
@@ -1561,15 +1725,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1601,6 +1756,12 @@
|
||||
}
|
||||
// Restore lastSeekTarget so toggle-play still works after render
|
||||
lastSeekTarget = savedLastSeekTarget;
|
||||
// Restore library sidebar scroll position so deleting/editing items
|
||||
// doesn't bounce the user back to the top of the list
|
||||
if (state.historyOpen && __prevHistoryScroll > 0) {
|
||||
const newList = document.querySelector(".history-list");
|
||||
if (newList) newList.scrollTop = __prevHistoryScroll;
|
||||
}
|
||||
}
|
||||
|
||||
function renderServerStatus() {
|
||||
@@ -1621,6 +1782,49 @@
|
||||
return `<span class="server-status connected" title="${modeTitle}"><span class="status-dot"></span>Connected${modeLabel}</span>`;
|
||||
}
|
||||
|
||||
function renderLicenseBlock() {
|
||||
const lic = state.license;
|
||||
const tier = !isLicensed()
|
||||
? { label: "Unlicensed", className: "unlicensed" }
|
||||
: isProTier()
|
||||
? { label: "Pro", className: "pro" }
|
||||
: { label: "Core", className: "core" };
|
||||
const expiresLine = lic.expiresAt
|
||||
? `Expires ${new Date(lic.expiresAt).toLocaleDateString()}`
|
||||
: (isLicensed() ? "Never expires" : "");
|
||||
return `
|
||||
<div class="license-block">
|
||||
<div class="lic-row">
|
||||
<strong style="font-size:12px;color:#e2e8f0;">License</strong>
|
||||
<span class="lic-tier ${tier.className}">${tier.label}${lic.isTrial ? " (trial)" : ""}</span>
|
||||
</div>
|
||||
${isLicensed() ? `
|
||||
<div class="lic-meta">
|
||||
${lic.licenseId ? `<div>ID: <span class="lic-id">${escHtml(lic.licenseId.slice(0, 8))}…</span></div>` : ""}
|
||||
${expiresLine ? `<div>${expiresLine}</div>` : ""}
|
||||
<div>Entitlements: ${(lic.entitlements || []).map(e => escHtml(e)).join(", ") || "none"}</div>
|
||||
</div>
|
||||
<div class="lic-actions">
|
||||
${!isProTier() ? `<a class="lic-btn" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener" style="background:#a855f7;color:#fff;border-color:#a855f7;">Upgrade to Pro</a>` : ""}
|
||||
<button class="lic-btn danger" onclick="deactivateLicense()">Deactivate</button>
|
||||
</div>
|
||||
` : `
|
||||
<div class="lic-meta">No active license. Activate one below to unlock the app.</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProUpsell(featureName, description) {
|
||||
return `
|
||||
<div class="pro-upsell">
|
||||
<div class="pro-title">${escHtml(featureName)} · Pro feature</div>
|
||||
<div class="pro-desc">${escHtml(description)}</div>
|
||||
<a class="pro-cta" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener">Upgrade to Pro →</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSettingsModal() {
|
||||
return `
|
||||
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
|
||||
@@ -1630,6 +1834,7 @@
|
||||
<button class="close-btn" onclick="toggleSettings()">×</button>
|
||||
</div>
|
||||
<div class="settings-modal-body">
|
||||
${renderLicenseBlock()}
|
||||
<label class="field-label">API Key</label>
|
||||
${state.hasServerKey ? `
|
||||
<div class="ytdlp-status ytdlp-ok" style="margin-top:0;margin-bottom:8px">
|
||||
@@ -1661,7 +1866,13 @@
|
||||
|
||||
${renderCookieStatus()}
|
||||
|
||||
${renderSubscriptions()}
|
||||
${hasEntitlement("subscriptions")
|
||||
? renderSubscriptions()
|
||||
: `<label class="field-label">Subscriptions</label>${renderProUpsell("Channel subscriptions", "Subscribe to YouTube channels and podcast feeds, then auto-process new uploads on a schedule. Available on the Pro tier.")}`}
|
||||
|
||||
${hasEntitlement("library")
|
||||
? renderLibraryTransfer()
|
||||
: `<label class="field-label">Library Transfer</label>${renderProUpsell("Library import/export", "Bulk-export your full library (summaries, folders, subscriptions) and re-import it on another instance. Available on the Pro tier.")}`}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -1669,12 +1880,63 @@
|
||||
`;
|
||||
}
|
||||
|
||||
async function shutdownServer() {
|
||||
if (!confirm("Shut down the server? You'll need to relaunch the app to use it again.")) return;
|
||||
// ── Library Transfer ──────────────────────────────────────────────────
|
||||
|
||||
function renderLibraryTransfer() {
|
||||
return '<label class="field-label" style="margin-top:12px;">Library Transfer</label>' +
|
||||
'<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
|
||||
'<span style="font-size:11px;color:#94a3b8;line-height:1.5;">Export your full library (summaries, folders, subscriptions) to transfer between devices, or import a library from another instance.</span>' +
|
||||
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' +
|
||||
'<button onclick="exportLibrary()" style="padding:6px 14px;font-size:12px;font-weight:600;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;" ' +
|
||||
'onmouseover="this.style.background=\'#4f46e5\'" onmouseout="this.style.background=\'#6366f1\'">Export Library</button>' +
|
||||
'<label style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;" ' +
|
||||
'onmouseover="this.style.background=\'#334155\';this.style.color=\'#e2e8f0\'" onmouseout="this.style.background=\'#1e293b\';this.style.color=\'#94a3b8\'">' +
|
||||
'Import Library' +
|
||||
'<input type="file" accept=".json" style="display:none" onchange="importLibrary(this.files[0])">' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div id="library-transfer-result"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function exportLibrary() {
|
||||
try {
|
||||
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 {}
|
||||
const res = await fetch(`${API_BASE}/api/library/export`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "youtube-summarizer-library.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Library exported!", "✓");
|
||||
} catch (e) {
|
||||
showToast("Export failed: " + e.message, "!");
|
||||
}
|
||||
}
|
||||
|
||||
async function importLibrary(file) {
|
||||
if (!file) return;
|
||||
const resultEl = document.getElementById("library-transfer-result");
|
||||
try {
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:#fbbf24;font-size:12px;">Importing...</span>';
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
const res = await fetch(`${API_BASE}/api/library/import`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.error) throw new Error(result.error);
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:#4ade80;font-size:12px;">Imported ' + result.imported + ' sessions (' + result.skipped + ' already existed)</span>';
|
||||
await loadHistory();
|
||||
render();
|
||||
showToast("Library imported: " + result.imported + " sessions added", "✓");
|
||||
} catch (e) {
|
||||
if (resultEl) resultEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Import failed: ' + escHtml(e.message) + '</span>';
|
||||
showToast("Import failed: " + e.message, "!");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscriptions ──────────────────────────────────────────────────────
|
||||
@@ -1716,12 +1978,12 @@
|
||||
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;
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 12px; margin-top:8px; padding:10px 12px;
|
||||
background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.15);
|
||||
border-radius:10px;">
|
||||
<span style="font-size:16px">📡</span>
|
||||
<span style="font-size:12px; color:#a78bfa; font-weight:500; flex:1;">
|
||||
Channel detected — subscribe to auto-process new videos
|
||||
<span style="font-size:12px; color:#a78bfa; font-weight:500; flex:1; min-width:160px;">
|
||||
Channel detected — subscribe to track new videos
|
||||
</span>
|
||||
<label style="font-size:10px; color:#64748b; white-space:nowrap;">Since:</label>
|
||||
<input type="date" value="${escHtml(state.addingSubSince || todayStr)}"
|
||||
@@ -1730,6 +1992,13 @@
|
||||
style="padding:5px 6px; font-size:11px;
|
||||
border:1px solid rgba(139,92,246,0.2); border-radius:6px; outline:none;
|
||||
background:#0f172a; color:#94a3b8; cursor:pointer;" />
|
||||
<label style="display:inline-flex; align-items:center; gap:6px; font-size:11px; color:${state.addingSubAuto ? "#fbbf24" : "#94a3b8"}; cursor:pointer; white-space:nowrap;"
|
||||
title="When ON, new videos from this subscription bypass the approval queue and start processing immediately.">
|
||||
<input type="checkbox" ${state.addingSubAuto ? "checked" : ""}
|
||||
onchange="state.addingSubAuto=this.checked; render()"
|
||||
style="accent-color:#fbbf24; cursor:pointer;" />
|
||||
⚡ Auto-process new videos
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1741,10 +2010,16 @@
|
||||
state.addingSubLoading = true;
|
||||
render();
|
||||
try {
|
||||
const auto = !!state.addingSubAuto;
|
||||
const res = await fetch(`${API_BASE}/api/subscriptions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, since: state.addingSubSince || undefined, type: podcast ? "podcast" : undefined }),
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
since: state.addingSubSince || undefined,
|
||||
type: podcast ? "podcast" : undefined,
|
||||
autoDownload: auto,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
@@ -1753,10 +2028,14 @@
|
||||
const label = podcast ? url.split("/").pop() || "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
|
||||
state.url = "";
|
||||
state.addingSubSince = "";
|
||||
state.addingSubAuto = false;
|
||||
state.addingSubLoading = false;
|
||||
await loadSubscriptions();
|
||||
render();
|
||||
showToast(`Subscribed to ${label} — checking for ${podcast ? "episodes" : "videos"}...`, podcast ? "🎙" : "📡");
|
||||
showToast(
|
||||
`Subscribed to ${label}${auto ? " (auto-process ON)" : ""} — checking for ${podcast ? "episodes" : "videos"}...`,
|
||||
podcast ? "🎙" : (auto ? "⚡" : "📡")
|
||||
);
|
||||
} catch (e) {
|
||||
state.error = e.message;
|
||||
state.addingSubLoading = false;
|
||||
@@ -1951,6 +2230,29 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function toggleAutoDownload(id) {
|
||||
const sub = state.subscriptions.find(s => s.id === id);
|
||||
if (!sub) return;
|
||||
const next = !sub.autoDownload;
|
||||
// Optimistic update
|
||||
sub.autoDownload = next;
|
||||
render();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/auto-download`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: next }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.subscription) sub.autoDownload = !!data.subscription.autoDownload;
|
||||
render();
|
||||
} catch {
|
||||
// Roll back on failure
|
||||
sub.autoDownload = !next;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscriptions() {
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
return `
|
||||
@@ -1988,9 +2290,17 @@
|
||||
<span class="sub-since-link" onclick="event.stopPropagation(); this.nextElementSibling.showPicker?.()" title="Click to change date" style="cursor:pointer; text-decoration:underline dotted; text-underline-offset:2px;">Since ${sinceDate}</span><input type="date" value="${sinceIso}" style="position:absolute;opacity:0;width:0;height:0;pointer-events:none;" onchange="updateSubSince('${sub.id}', this.value)" />
|
||||
${sub.lastChecked ? " · Checked " + timeAgo(sub.lastChecked) : ""}
|
||||
${sub.paused ? " · Paused" : ""}
|
||||
${sub.autoDownload ? ' · <span style="color:#fbbf24">\u26A1 Auto-process</span>' : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-actions">
|
||||
<button class="sub-action" onclick="event.stopPropagation(); toggleAutoDownload('${sub.id}')"
|
||||
title="${sub.autoDownload ? "Auto-process: ON — new videos skip the queue and process immediately. Click to disable." : "Auto-process: OFF — new videos require approval in the queue. Click to enable auto-processing."}"
|
||||
style="${sub.autoDownload ? "color:#fbbf24" : ""}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="${sub.autoDownload ? "#fbbf24" : "none"}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="sub-action" onclick="event.stopPropagation(); togglePauseSubscription('${sub.id}')"
|
||||
title="${sub.paused ? "Resume" : "Pause"}">
|
||||
${sub.paused ? "\u25B6" : "\u23F8"}
|
||||
@@ -2253,7 +2563,7 @@
|
||||
</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>` : ""}
|
||||
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn chunk-clip-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index})" title="Add topic to clips">📎</span>` : ""}
|
||||
<div class="chunk-arrow" onclick="toggleChunk(${index})" style="cursor:pointer">\u25BE</div>
|
||||
</div>
|
||||
<div class="chunk-body">
|
||||
@@ -2263,7 +2573,7 @@
|
||||
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>` : ""}
|
||||
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index}, ${ei})" title="Add this line to clips">📎</span>` : ""}
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
@@ -2333,8 +2643,13 @@
|
||||
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>' +
|
||||
'Cookies authenticate your server with YouTube to avoid bot detection blocks and access restricted videos.<br><br>' +
|
||||
'<strong style="color:#94a3b8;">To set up:</strong><br>' +
|
||||
'1. Install the "Get cookies.txt LOCALLY" browser extension on your laptop<br>' +
|
||||
'2. Go to youtube.com and make sure you\'re signed in<br>' +
|
||||
'3. Click the extension icon and export cookies for youtube.com<br>' +
|
||||
'4. Upload the downloaded cookies.txt file here<br><br>' +
|
||||
'<span style="color:#fbbf24;">Note:</span> Cookies expire after ~2 weeks. You\'ll need to re-upload periodically.</span>' +
|
||||
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + '</div>' +
|
||||
'</div>';
|
||||
} else if (state.cookieMethod === "cookies.txt") {
|
||||
@@ -2585,6 +2900,12 @@
|
||||
const data = await res.json();
|
||||
state.historySessions = data.sessions || {};
|
||||
state.historyMeta = data.meta || { folders: [], uncategorized: [] };
|
||||
// Hydrate collapsed-folder UI state from persisted server meta
|
||||
state.collapsedFolders = new Set(
|
||||
(state.historyMeta.folders || [])
|
||||
.filter(f => f.collapsed)
|
||||
.map(f => f.id)
|
||||
);
|
||||
state.historyLoaded = true;
|
||||
} catch {
|
||||
state.historySessions = {};
|
||||
@@ -2660,12 +2981,18 @@
|
||||
// Surgically update just the sidebar instead of full re-render
|
||||
const sidebar = document.querySelector(".history-sidebar");
|
||||
if (sidebar && state.historyOpen) {
|
||||
// Capture current scroll so the user stays where they were
|
||||
const prevList = sidebar.querySelector(".history-list");
|
||||
const prevScroll = prevList ? prevList.scrollTop : 0;
|
||||
const sidebarHtml = renderHistorySidebar();
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = sidebarHtml;
|
||||
// Replace sidebar content (keep the element to avoid re-animation)
|
||||
const newSidebar = temp.querySelector(".history-sidebar");
|
||||
if (newSidebar) sidebar.innerHTML = newSidebar.innerHTML;
|
||||
// Restore scroll position on the freshly created list
|
||||
const newList = sidebar.querySelector(".history-list");
|
||||
if (newList && prevScroll > 0) newList.scrollTop = prevScroll;
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
@@ -2714,9 +3041,19 @@
|
||||
}
|
||||
|
||||
function toggleFolder(id) {
|
||||
if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id);
|
||||
else state.collapsedFolders.add(id);
|
||||
const nowCollapsed = !state.collapsedFolders.has(id);
|
||||
if (nowCollapsed) state.collapsedFolders.add(id);
|
||||
else state.collapsedFolders.delete(id);
|
||||
// Mirror into local meta so re-renders stay consistent without a refetch
|
||||
const folder = state.historyMeta.folders.find(f => f.id === id);
|
||||
if (folder) folder.collapsed = nowCollapsed;
|
||||
render();
|
||||
// Persist to server (fire-and-forget; UI already updated optimistically)
|
||||
fetch(`${API_BASE}/api/history/folders/${id}/collapsed`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ collapsed: nowCollapsed }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Drag & drop with insertion indicator
|
||||
@@ -2927,6 +3264,7 @@
|
||||
function renderHistorySidebar() {
|
||||
const totalSessions = Object.keys(state.historySessions).length;
|
||||
const { folders, uncategorized } = state.historyMeta;
|
||||
const historyEntitled = hasEntitlement("history");
|
||||
|
||||
return `
|
||||
<div class="history-sidebar-overlay" onclick="toggleHistory()"></div>
|
||||
@@ -2934,15 +3272,22 @@
|
||||
<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>
|
||||
${historyEntitled ? `
|
||||
<button class="history-action-btn" onclick="createFolder()" title="New Folder">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
</button>
|
||||
` : ""}
|
||||
<button class="close-btn" onclick="toggleHistory()" style="width:30px;height:30px;font-size:16px">×</button>
|
||||
</div>
|
||||
</div>
|
||||
${!historyEntitled ? `
|
||||
<div style="padding: 16px;">
|
||||
${renderProUpsell("Summary library", "Save, organize, and revisit every summary you generate. Folders, drag-and-drop, search. Available on the Pro tier.")}
|
||||
</div>
|
||||
` : `
|
||||
<div class="history-list"
|
||||
ondragover="onListDragOver(event)"
|
||||
ondrop="onDropToUncategorized(event)">
|
||||
@@ -2989,6 +3334,7 @@
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -3023,6 +3369,9 @@
|
||||
}
|
||||
}
|
||||
function toggleClipPanel() {
|
||||
// Pro-tier feature: silently no-op if the license lacks the entitlement.
|
||||
// Existing clipCollection in localStorage is preserved across upgrades.
|
||||
if (!hasEntitlement("clips")) return;
|
||||
state.clipPanelOpen = !state.clipPanelOpen;
|
||||
render();
|
||||
}
|
||||
@@ -3302,6 +3651,12 @@
|
||||
// ── Clip Collection ───────────────────────────────────────────────────────
|
||||
|
||||
function addToClipCollection(sessionId, chunkIndex, entryIndex) {
|
||||
// Pro-tier feature. Defense-in-depth: even if a Core user reaches this
|
||||
// (e.g. via stale UI before re-render), refuse the add and prompt upgrade.
|
||||
if (!hasEntitlement("clips")) {
|
||||
showToast("Clips are a Pro feature. Upgrade to unlock.", "🔒", 3500);
|
||||
return;
|
||||
}
|
||||
// Check if already in collection
|
||||
const exists = state.clipCollection.find(c =>
|
||||
c.sessionId === sessionId && c.chunkIndex === chunkIndex &&
|
||||
@@ -3581,6 +3936,115 @@
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
// ── Keysat license helpers ───────────────────────────────────────────────
|
||||
function hasEntitlement(name) {
|
||||
return !!(state.license && state.license.entitlements && state.license.entitlements.includes(name));
|
||||
}
|
||||
function isLicensed() {
|
||||
return state.license && state.license.state === "licensed" && hasEntitlement("core");
|
||||
}
|
||||
function isProTier() {
|
||||
// Pro tier is defined by the entitlements that distinguish it from Core,
|
||||
// i.e. subscriptions + clips. (history + library are now Core, so they
|
||||
// don't separate the tiers anymore.)
|
||||
return isLicensed() && hasEntitlement("subscriptions") && hasEntitlement("clips");
|
||||
}
|
||||
async function loadLicenseStatus() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/license-status`);
|
||||
const data = await res.json();
|
||||
state.license = {
|
||||
loaded: true,
|
||||
state: data.state || "unlicensed",
|
||||
reason: data.reason || null,
|
||||
licenseId: data.licenseId || null,
|
||||
entitlements: data.entitlements || [],
|
||||
expiresAt: data.expiresAt || null,
|
||||
isTrial: !!data.isTrial,
|
||||
productSlug: data.productSlug || "youtube-summarizer",
|
||||
keysatBaseUrl: data.keysatBaseUrl || "",
|
||||
};
|
||||
} catch {
|
||||
state.license = {
|
||||
...state.license,
|
||||
loaded: true,
|
||||
state: "unlicensed",
|
||||
reason: "license_status_unreachable",
|
||||
};
|
||||
}
|
||||
}
|
||||
async function activateLicense() {
|
||||
const key = (state.licenseActivationKey || "").trim();
|
||||
if (!key) {
|
||||
state.licenseActivationError = "Paste a license key first.";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
state.licenseActivating = true;
|
||||
state.licenseActivationError = null;
|
||||
render();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/license/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ license_key: key }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
state.license = {
|
||||
loaded: true,
|
||||
state: data.state,
|
||||
reason: data.reason,
|
||||
licenseId: data.licenseId,
|
||||
entitlements: data.entitlements || [],
|
||||
expiresAt: data.expiresAt,
|
||||
isTrial: !!data.isTrial,
|
||||
productSlug: data.productSlug || "youtube-summarizer",
|
||||
keysatBaseUrl: data.keysatBaseUrl || "",
|
||||
};
|
||||
state.licenseActivationKey = "";
|
||||
state.licenseActivationError = null;
|
||||
showToast("License activated.", "✓");
|
||||
// Now that we're licensed, kick off the loads we deferred at boot.
|
||||
await loadAfterLicensed();
|
||||
} else {
|
||||
state.licenseActivationError = data.message || data.reason || "Activation failed.";
|
||||
}
|
||||
} catch (e) {
|
||||
state.licenseActivationError = "Could not reach the server.";
|
||||
} finally {
|
||||
state.licenseActivating = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
async function deactivateLicense() {
|
||||
if (!confirm("Remove the license from this server? You'll need to paste the key again to re-activate.")) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/license/deactivate`, { method: "POST" });
|
||||
} catch {}
|
||||
await loadLicenseStatus();
|
||||
// Clear in-memory data that the deactivated user can no longer see.
|
||||
state.subscriptions = [];
|
||||
state.subsLoaded = false;
|
||||
state.historySessions = {};
|
||||
state.historyMeta = { folders: [], uncategorized: [] };
|
||||
state.historyLoaded = false;
|
||||
render();
|
||||
}
|
||||
async function loadAfterLicensed() {
|
||||
// Lightweight version of init's secondary loads. Only fetches what
|
||||
// the current entitlements actually permit.
|
||||
await loadHistory().catch(() => {});
|
||||
if (hasEntitlement("subscriptions")) {
|
||||
await loadSubscriptions().catch(() => {});
|
||||
try { await pollAutoQueue(); } catch {}
|
||||
}
|
||||
}
|
||||
function upgradeToProUrl() {
|
||||
const base = state.license.keysatBaseUrl || "https://licensing.keysat.xyz";
|
||||
return `${base.replace(/\/$/, "")}/buy/${state.license.productSlug || "youtube-summarizer"}`;
|
||||
}
|
||||
|
||||
function showToast(message, icon = "✓", duration = 4000) {
|
||||
const container = document.getElementById("toast-container");
|
||||
if (!container) return;
|
||||
@@ -3599,12 +4063,15 @@
|
||||
// Load persisted clip collection
|
||||
loadClipCollection();
|
||||
|
||||
// Fetch health + network mode in parallel
|
||||
// Fetch license-status + health + network mode in parallel. License is
|
||||
// load-bearing: when unlicensed, the activation overlay replaces the app
|
||||
// entirely and we skip the loads that would 402 anyway (history,
|
||||
// subscriptions, auto-queue).
|
||||
Promise.all([
|
||||
loadLicenseStatus(),
|
||||
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]) => {
|
||||
]).then(async ([_, health, net]) => {
|
||||
state.hasServerKey = !!health.hasServerKey;
|
||||
if (!health.installed) {
|
||||
state.ytdlpVersion = false;
|
||||
@@ -3622,13 +4089,20 @@
|
||||
}
|
||||
if (net) state.lanMode = !!net.lan;
|
||||
state.serverStatus = "connected";
|
||||
|
||||
// Only load licensed-only data when the activation gate would let us.
|
||||
if (isLicensed()) {
|
||||
if (hasEntitlement("history")) await loadHistory().catch(() => {});
|
||||
if (hasEntitlement("subscriptions")) {
|
||||
await loadSubscriptions().catch(() => {});
|
||||
try {
|
||||
const added = await pollAutoQueue();
|
||||
if (added) render();
|
||||
} catch {}
|
||||
startBgPoll();
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user