Files
t-youtube/frontend/src/routes/YoutubeDashboard.svelte
T

907 lines
44 KiB
Svelte

<script>
import { onMount, tick } from 'svelte';
let videos = $state([]);
let selectedVideo = $state(null);
let activeTab = $state('pending');
let isLoading = $state(false);
let isSyncing = $state(false);
let syncMessage = $state('');
let pendingCount = $state(0);
let theme = $state(localStorage.getItem('theme') || 'dark');
let apiToken = $state(localStorage.getItem('t-yt-token') || '');
let apiTokenInput = $state('');
let showTokenSetup = $state(false);
let summaryPanel = $state(null);
let notes = $state({});
let activeNote = $state(null);
let queueToAgent = $state(false);
let showHelp = $state(false);
let channelFilter = $state('');
let allChannels = $state([]);
let editingCategories = $state(false);
let prevSelectedId = $state(null);
// Search state
let searchQuery = $state('');
let searchResults = $state([]);
let isSearching = $state(false);
let showSearchResults = $state(false);
// Batch select state
let selectedVideoIds = $state(new Set());
let showBatchBar = $state(false);
// Stats state
let statsData = $state(null);
let showStats = $state(false);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
// Stats fetcher
async function loadStats() {
try {
const res = await fetch('/api/stats');
if (res.ok) statsData = await res.json();
} catch(e) {}
}
$effect(() => {
if (activeTab === 'stats' && !statsData) loadStats();
});
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
} else if (activeTab === 'pending' && !channelFilter) {
loadVideos();
}
});
// Auto-generated categories (AI-categorized)
const AUTO_CATEGORIES = {"AI Engineer":"AI & Tech","AI-Fan AI研究室-帆哥":"AI & Tech","AI大模型":"AI & Tech","AI探索与发现":"AI & Tech","Aurora Tech":"AI & Tech","Claude":"AI & Tech","ColdFusion":"AI & Tech","Data Is Beautiful":"AI & Tech","DeepLearningAI":"AI & Tech","Google":"AI & Tech","Google Cloud Tech":"AI & Tech","Lex Fridman":"AI & Tech","NiceKate AI":"AI & Tech","PC Sim":"AI & Tech","Stanford Online":"AI & Tech","The Linux Foundation":"AI & Tech","回到Axton":"AI & Tech","回形针PaperClip":"AI & Tech","林亦LYi":"AI & Tech","硅谷101播客":"AI & Tech","老石谈芯":"AI & Tech","鲲鹏Talk":"AI & Tech","3D Beast":"3D & Design","Arrimus3D Modeling and Design":"3D & Design","Blender":"3D & Design","Blender Guru":"3D & Design","Creators 3D":"3D & Design","Designfusion":"3D & Design","Foundry":"3D & Design","Grant Abbitt (Gabbitt)":"3D & Design","Gravity Sketch":"3D & Design","Hyperganic":"3D & Design","MSLattice":"3D & Design","Manolo Remiddi":"3D & Design","Rhino 3D (Rhinoceros3d official)":"3D & Design","Shaper3d":"3D & Design","nTop":"3D & Design","spherene AG":"3D & Design","魔界造物":"3D & Design","Chuck Severance":"Programming","Coursera":"Programming","freeCodeCamp.org":"Programming","Kevin Stratvert":"Programming","Leila Gharani":"Programming","LinkedIn Learning":"Programming","Metabase":"Programming","n8n":"Programming","The Coding Gopher":"Programming","Brian Casel":"Programming","code秘密花园":"Programming","cs tech":"Programming","fequalsf":"Programming","openscreencast":"Programming","技术爬爬虾 TechShrimp":"Programming","林本兔Limbuntu":"Programming","Best Choice Review":"Hardware & Reviews","Marques Brownlee":"Hardware & Reviews","TESTV":"Hardware & Reviews","先看评测":"Hardware & Reviews","极客湾Geekerwan":"Hardware & Reviews","硬核拆解":"Hardware & Reviews","笔吧评测室":"Hardware & Reviews","差评硬件部":"Hardware & Reviews","充电头网":"Hardware & Reviews","Apple Developer":"Apple Dev","Made by Google":"Apple Dev","Allen的分享":"Apple Dev","Sypnotix":"Apple Dev","8K World":"Photography","Can Fiona":"Photography","小天fotos":"Photography","影像极客Fotogeeker":"Photography","摄影师PHiL":"Photography","虚空光影CosmosFilm":"Photography","路易斯 LouisDrone":"Photography","Bruno Mars":"Music","GraceLeeMusic":"Music","Katie Melua":"Music","The Weeknd":"Music","TheWeekndVEVO":"Music","杰威爾歌詞MV頻道JVR Lyric MV":"Music","薛汀哲":"Music","Salah Trainer":"Sports","Skills N Talents (swimming)":"Sports","TotalSports TV":"Sports","WorldSBK":"Sports","Everyday Cycling":"Sports","爱羽客羽毛球网":"Sports","adidas":"Sports","Sneaks & Feet极客鞋谈":"Automotive","TOP的遥控玩具":"Automotive","悟空的日常":"Automotive"};
let categories = $state({...AUTO_CATEGORIES});
let channels = $derived([...new Set(videos.map(v => v.channel_name))].sort());
let filteredVideos = $derived(
channelFilter
? channelFilter.startsWith('📁')
? videos.filter(v => (categories[v.channel_name] || '') === channelFilter.replace('📁 ', ''))
: videos.filter(v => v.channel_name === channelFilter)
: videos
);
let catList = $derived([...new Set(Object.values(categories).flat())].sort());
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
// Search debouncer
let searchTimeout = null;
async function handleSearch(query) {
searchQuery = query;
if (!query || query.length < 2) {
showSearchResults = false;
return;
}
isSearching = true;
try {
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
if (res.ok) {
searchResults = await res.json();
showSearchResults = true;
}
} catch(e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}
// Batch operations
function toggleSelect(videoId) {
const newSet = new Set(selectedVideoIds);
if (newSet.has(videoId)) {
newSet.delete(videoId);
} else {
newSet.add(videoId);
}
selectedVideoIds = newSet;
showBatchBar = newSet.size > 0;
}
function selectAll() {
selectedVideoIds = new Set(filteredVideos.map(v => v.video_id));
showBatchBar = true;
}
function clearSelection() {
selectedVideoIds = new Set();
showBatchBar = false;
}
async function batchUpdate(status) {
if (selectedVideoIds.size === 0) return;
const ids = Array.from(selectedVideoIds);
try {
const res = await fetch('/api/videos/batch', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ video_ids: ids, status })
});
if (res.ok) {
// Remove selected from local list
videos = videos.filter(v => !ids.includes(v.video_id));
clearSelection();
loadPendingCount();
}
} catch(e) {
console.error('Batch update failed:', e);
}
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatViews(count) {
if (!count || count < 1000) return count || '';
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1000000).toFixed(1)}M`;
}
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.className = theme;
}
async function loadVideos() {
isLoading = true;
try {
if (activeTab === 'notes') {
// Notes tab: show only pending videos that have notes
const res = await fetch(`/api/videos?status=pending&limit=50`);
if (res.ok) {
const all = await res.json();
videos = all.filter(v => noteIds.has(v.video_id));
if (videos.length > 0) selectedVideo = videos[0];
else selectedVideo = null;
}
} else {
const params = new URLSearchParams({status: activeTab, limit: '200'});
if (channelFilter && !channelFilter.startsWith('📁')) {
params.set('channel', channelFilter);
}
const res = await fetch(`/api/videos?${params}`);
if (res.ok) {
videos = await res.json();
if (videos.length > 0) {
const currentId = selectedVideo?.video_id;
const found = videos.find(v => v.video_id === currentId);
selectedVideo = found || videos[0];
} else {
selectedVideo = null;
}
}
}
} catch (err) {
console.error("Error loading videos:", err);
} finally {
isLoading = false;
}
}
async function syncSubscriptions() {
isSyncing = true;
syncMessage = 'Syncing...';
try {
const res = await fetch('/api/videos/fetch', { method: 'POST', headers: getAuthHeaders() });
if (res.ok) {
syncMessage = 'Sync started...';
setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
} else {
isSyncing = false;
syncMessage = 'Sync failed';
}
} catch (err) { isSyncing = false; syncMessage = 'Network error'; }
}
async function updateStatus(videoId, newStatus, silent = false) {
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ status: newStatus }) });
if (!silent) {
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
if (videos.length < 20) loadVideos();
}
}
async function generateSummary(videoId) {
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST', headers: getAuthHeaders() });
setTimeout(() => loadVideos(), 3000);
}
// ---- Server-side notes ----
async function loadNotes() {
try {
const res = await fetch('/api/notes');
if (res.ok) {
const all = await res.json();
const map = {};
for (const item of all) map[item.video_id] = item.note_text;
notes = map;
}
} catch(e) { console.error('Failed to load notes:', e); }
}
async function migrateLocalNotes() {
try {
const local = JSON.parse(localStorage.getItem('t-yt-notes') || '{}');
const keys = Object.keys(local).filter(k => local[k].trim());
if (keys.length === 0) return;
const missing = keys.filter(k => !notes[k]);
if (missing.length === 0) return;
const payload = {};
for (const k of missing) payload[k] = local[k];
await fetch('/api/notes/migrate', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ notes: payload })
});
await loadNotes();
} catch(e) {}
}
async function saveNote() {
const vid = selectedVideo?.video_id;
if (!vid) return;
const textarea = document.querySelector('#note-textarea');
const current = textarea ? textarea.value : (notes[vid] || '');
if (!current.trim()) return;
// Save to server
await fetch(`/api/notes/${vid}`, {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' } ),
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
});
// Update local state
notes = {...notes, [vid]: current};
activeNote = null;
queueToAgent = false;
}
// ---- Agent queue ----
async function loadAgentQueue() {
agentLoading = true;
try {
const res = await fetch('/api/agent-queue');
if (res.ok) agentItems = await res.json();
} catch(e) { console.error('Failed to load agent queue:', e); }
finally { agentLoading = false; }
}
async function removeAgentItem(id) {
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
agentItems = agentItems.filter(i => i.id !== id);
}
async function initAuth() {
// Check if auth is enabled on the backend
try {
const res = await fetch('/api/config');
if (res.ok) {
const config = await res.json();
if (config.auth_enabled && !apiToken) {
showTokenSetup = true;
return false;
}
}
} catch(e) {}
showTokenSetup = false;
return true;
}
async function setupToken() {
if (apiTokenInput.trim()) {
apiToken = apiTokenInput.trim();
localStorage.setItem('t-yt-token', apiToken);
showTokenSetup = false;
}
}
function getAuthHeaders(extraHeaders = {}) {
const headers = { ...extraHeaders };
if (apiToken) {
headers['Authorization'] = 'Bearer ' + apiToken;
}
return headers;
}
// ---- Lifecycle ----
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
// Scroll to top when switching videos
if (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0);
}
const prevId = prevSelectedId;
prevSelectedId = selectedVideo?.video_id || null;
// NOTE: auto-mark-read is now handled by scroll-to-bottom detection (see below)
// and explicit user actions (r/Enter key or Read button).
});
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
let scrollReadTimer = $state(null);
function handleSummaryScroll() {
if (!summaryPanel || !selectedVideo || activeTab !== 'pending') return;
const threshold = 100; // px from bottom
const atBottom = (summaryPanel.scrollHeight - summaryPanel.scrollTop - summaryPanel.clientHeight) < threshold;
if (atBottom) {
// Debounce to avoid rapid-fire updates
if (scrollReadTimer) clearTimeout(scrollReadTimer);
scrollReadTimer = setTimeout(() => {
const sv = selectedVideo;
if (sv && sv.summary && !sv.summary.includes('Error') && !sv.summary.includes('📺 Short clip')) {
updateStatus(sv.video_id, 'read', true);
}
}, 500);
}
}
$effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
onMount(async () => {
document.documentElement.className = theme;
await initAuth();
await loadNotes();
// Migrate localStorage notes to server (non-blocking)
migrateLocalNotes();
await loadVideos();
loaded = true;
loadPendingCount();
updateSyncTimer();
loadChannels();
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function updateSyncTimer() {
const el = document.getElementById('sync-timer');
if (!el) return;
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0);
if (now > next) next.setDate(next.getDate() + 1);
const diff = next - now;
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
el.textContent = `Next sync in ${h}h ${m}m`;
setTimeout(updateSyncTimer, 60000);
}
async function loadPendingCount() {
try {
const res = await fetch('/api/videos?status=pending&limit=201');
if (res.ok) {
const all = await res.json();
pendingCount = all.length >= 201 ? '200+' : all.length;
}
} catch(e) {}
}
async function loadChannels() {
try {
const res = await fetch('/api/channels');
if (res.ok) allChannels = await res.json();
} catch(e) {}
}
function handleKeydown(e) {
if (!videos.length) return;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
// If no video selected, let keyboard scroll the page naturally
if (!selectedVideo) return;
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo.video_id);
let nextIdx = idx;
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
if (idx < filteredVideos.length - 1) nextIdx = idx + 1; else return;
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
if (idx > 0) nextIdx = idx - 1; else return;
} else if (e.key === 'r' || e.key === 'm') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'read');
return;
} else if (e.key === 's') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'skipped');
return;
} else if (e.key === 'n') {
e.preventDefault();
if (selectedVideo) activeNote = selectedVideo.video_id;
return;
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeNote && selectedVideo && activeNote === selectedVideo.video_id) {
saveNote();
} else if (selectedVideo) {
updateStatus(selectedVideo.video_id, 'read');
}
return;
} else if (e.key === '?') {
e.preventDefault();
showHelp = !showHelp;
return;
} else {
return;
}
selectedVideo = filteredVideos[nextIdx];
scrollToVideo(nextIdx);
}
function scrollToVideo(index) {
const el = document.querySelector(`[data-video-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'instant' });
}
function autoFocus(node) {
requestAnimationFrame(() => node.focus());
}
function formatMarkdown(text) {
if (!text) return '<p class="opacity-50 italic">No summary yet.</p>';
let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold" style="color:var(--accent)">$1</strong>');
html = html.replace(/^\s*[-*]\s+(.*)$/gm, '<li class="ml-5 list-disc mb-1" style="color:var(--text-primary)">$1</li>');
html = html.split('\n').map(line => {
if (line.trim().startsWith('<li')) return line;
if (line.trim() === '') return '<div class="h-2"></div>';
return `<p class="mb-2 leading-relaxed">${line}</p>`;
}).join('');
return html;
}
function noteWordCount(noteText) {
if (!noteText) return 0;
return noteText.trim().split(/\s+/).length;
}
function formatDate(d) {
if (!d) return '';
return d.slice(0, 10);
}
// Stats display helpers
function formatStatsNumber(n) {
if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n/1000).toFixed(1)}K`;
return n.toString();
}
</script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
<!-- Token Setup Modal -->
{#if showTokenSetup}
<div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.5)">
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg max-w-md w-full mx-4"
style="border:1px solid var(--border)">
<h2 class="text-xl font-bold mb-2">API Token Required</h2>
<p class="text-sm mb-4" style="color:var(--text-secondary)">
This app requires an API token to save notes and manage videos.
Please enter the token configured on the server.
</p>
<input type="text" bind:value={apiTokenInput}
placeholder="Enter API token..." class="w-full px-3 py-2 rounded"
style="background:var(--bg-primary);border:1px solid var(--border);color:var(--text-primary)"
onkeydown={(e) => e.key === 'Enter' && setupToken()} />
<div class="flex space-x-2 mt-4">
<button onclick={setupToken}
class="px-4 py-2 rounded text-white font-bold"
style="background:var(--accent)">
Submit
</button>
</div>
</div>
</div>
{/if}
<header style="background:var(--bg-secondary);border-bottom:1px solid var(--border);" class="flex items-center justify-between px-5 py-2.5">
<div class="flex items-center space-x-3">
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
<span class="text-[10px] px-2 py-0.5 rounded font-semibold uppercase tracking-widest" style="background:var(--bg-tertiary);color:var(--text-muted)">text feed</span>
</div>
<div class="flex items-center space-x-3">
<button onclick={syncSubscriptions} disabled={isSyncing}
class="px-3 py-1.5 text-xs font-bold rounded-lg transition"
style="background:var(--accent);color:white">
{isSyncing ? 'Syncing...' : 'Sync'}
</button>
<button onclick={toggleTheme}
class="text-sm px-2 py-1 rounded transition" style="color:var(--text-muted)"
title="Toggle theme">
{theme === 'dark' ? '☀' : '☾'}
</button>
{#if !isSyncing}
<span class="text-[10px]" style="color:var(--text-muted)" id="sync-timer"></span>
{/if}
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left sidebar -->
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<!-- Tabs -->
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped','notes','stats','🤖 Agent'] as tab}
<button onclick={() => activeTab = tab}
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab === 'stats' ? '📊 Stats' : tab === '🤖 Agent' ? '🤖 Agent' : tab}
</button>
{/each}
</div>
<!-- Search bar (shown in pending/read/skipped tabs) -->
{#if activeTab !== '🤖 Agent' && activeTab !== 'stats' && activeTab !== 'notes'}
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
<div class="relative">
<input type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search videos..."
class="w-full text-[11px] p-1.5 rounded border"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
onfocus={() => { if (searchResults.length) showSearchResults = true; }}
onblur={() => setTimeout(() => showSearchResults = false, 200)}>
{#if isSearching}
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)"></div>
{/if}
</div>
</div>
{/if}
<!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
{#if agentLoading}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading...</div>
{:else if agentItems.length === 0}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">
<p>No items queued.<br>Write a note on a video and check "Send to Agent" to add one.</p>
</div>
{:else}
{#each agentItems as item}
<div class="p-3 border-b" style="border-color:var(--border)">
<div class="flex items-start space-x-2">
<div class="min-w-0 flex-1">
<p class="text-[11px] font-bold leading-tight line-clamp-2" style="color:var(--text-primary)">{item.title || 'Unknown'}</p>
<p class="text-[10px] mt-1" style="color:var(--accent)">{item.channel_name || ''}</p>
<p class="text-[10px] mt-1" style="color:var(--text-muted)">Prompt: {item.user_prompt.slice(0, 120)}{item.user_prompt.length > 120 ? '...' : ''}</p>
<div class="flex items-center space-x-2 mt-1.5">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
</div>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] mt-1 px-2 py-0.5 rounded border"
style="color:var(--text-muted);border-color:var(--border)">
Remove
</button>
{/if}
</div>
</div>
</div>
{/each}
{/if}
</div>
{:else if activeTab === 'stats'}
<!-- Stats Dashboard -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
{#if !statsData}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading stats...</div>
{:else}
<!-- Summary cards -->
<div class="grid grid-cols-2 gap-2">
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{formatStatsNumber(statsData.total || 0)}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Total Videos</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.summary_rate || 0}%</div>
<div class="text-[9px]" style="color:var(--text-muted)">Summary Rate</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.channels || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Channels</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.last_7_days || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Last 7 Days</div>
</div>
</div>
<!-- Status breakdown -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">STATUS BREAKDOWN</div>
{#each Object.entries(statsData.status_breakdown || {}) as [status, count]}
<div class="flex justify-between items-center py-1">
<span class="text-[11px]" style="color:var(--text-primary)">{status}</span>
<span class="text-[11px] font-bold" style="color:var(--accent)">{count}</span>
</div>
{/each}
</div>
<!-- Top channels -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">TOP CHANNELS</div>
{#each (statsData.top_channels || []).slice(0, 8) as ch}
<div class="flex justify-between items-center py-1">
<span class="text-[10px] truncate flex-1 mr-2" style="color:var(--text-primary)">{ch.name}</span>
<span class="text-[10px]" style="color:var(--text-muted)">{ch.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Channel filter bar -->
{#if (allChannels.length || channels.length) > 1}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
<select bind:value={channelFilter}
class="w-full text-[11px] p-1.5 rounded border bg-transparent"
style="color:var(--text-primary);border-color:var(--border);background:var(--bg-tertiary)">
<option value="">All Channels</option>
{#if catList.length}
<option disabled>── Categories ──</option>
{#each catList as cat}
<option value={'📁 ' + cat}>📁 {cat}</option>
{/each}
<option disabled>── Channels ──</option>
{/if}
{#each allChannels.length ? allChannels.map(c => c.channel_name) : channels as ch}
<option value={ch}>{ch}</option>
{/each}
</select>
<button onclick={() => editingCategories = !editingCategories}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
{editingCategories ? 'Done' : '✎ Edit Categories'}
</button>
<button onclick={async () => {
const res = await fetch('/auto_categories.json');
if (res.ok) {
const data = await res.json();
for (const [k, v] of Object.entries(data)) categories[k] = v;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
loadChannels();
}
}}
class="w-full text-[10px] py-1 rounded border transition text-center"
style="color:var(--text-muted);border-color:var(--border)">
↻ Reset to Auto
</button>
</div>
{/if}
{#if editingCategories}
<div class="overflow-y-auto max-h-48 p-2 space-y-1" style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">
<div class="text-[10px] pb-1" style="color:var(--text-muted)">Type category name next to each channel:</div>
<input class="w-full text-[11px] p-1 rounded border mb-1" style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="New category name..." id="new-cat-input"
onkeydown={e => { if (e.key === 'Enter') { const v = e.target.value.trim(); if (v) { categories[v] = []; localStorage.setItem('t-yt-categories', JSON.stringify(categories)); e.target.value = ''; } } }}>
{#each allChannels as ch}
<div class="flex items-center space-x-2 text-[11px]">
<span class="flex-1 truncate" style="color:var(--text-primary)">{ch.channel_name}</span>
<input class="w-20 text-[10px] p-0.5 rounded border text-center"
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
placeholder="cat"
value={categories[ch.channel_name] || ''}
oninput={e => {
categories[ch.channel_name] = e.target.value;
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
}}>
</div>
{/each}
</div>
{/if}
<!-- Video list -->
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each filteredVideos as video (video.video_id)}
{@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-2.5 transition flex items-start space-x-2 outline-none focus:outline-none"
style="border-left:2px solid {selectedVideo?.video_id === video.video_id ? 'var(--accent)' : 'transparent'};
background:{selectedVideo?.video_id === video.video_id ? 'var(--bg-tertiary)' : 'transparent'}">
<!-- Batch select checkbox -->
<input type="checkbox"
checked={selectedVideoIds.has(video.video_id)}
onclick={(e) => { e.stopPropagation(); toggleSelect(video.video_id); }}
class="w-3 h-3 mt-1 rounded flex-shrink-0"
style="accent-color:var(--accent)">
<img src={video.thumbnail_url} alt="" class="w-16 h-10 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 flex-1">
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
<h3 class="text-[11px] font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[9px]" style="color:var(--text-muted)">{video.publish_date || ''}</span>
{#if video.duration}
<span class="text-[9px]" style="color:var(--text-muted)">{formatDuration(video.duration)}</span>
{/if}
{#if video.view_count && video.view_count > 0}
<span class="text-[9px]" style="color:var(--text-muted)">{formatViews(video.view_count)}</span>
{/if}
{#if noteIds.has(video.video_id)}
<span class="text-[9px]">📝</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
{/if}
</aside>
<!-- Batch action bar -->
{#if showBatchBar}
<div class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-40 px-4 py-2 rounded-full shadow-lg flex items-center space-x-3"
style="background:var(--bg-secondary);border:1px solid var(--border)">
<span class="text-xs font-bold" style="color:var(--accent)">{selectedVideoIds.size} selected</span>
<button onclick={() => batchUpdate('read')} class="text-[10px] px-3 py-1 text-white font-bold rounded" style="background:var(--accent)">Read All</button>
<button onclick={() => batchUpdate('skipped')} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-secondary);border-color:var(--border)">Skip All</button>
<button onclick={clearSelection} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-muted);border-color:var(--border)">Clear</button>
</div>
{/if}
<!-- Right: summary panel -->
<main bind:this={summaryPanel} onscroll={handleSummaryScroll} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if activeTab === '🤖 Agent'}
<div class="p-6 max-w-3xl mx-auto">
<h2 class="text-sm font-black tracking-wider mb-4" style="color:var(--accent)">🤖 Agent Queue</h2>
{#if agentItems.length === 0}
<p class="text-xs opacity-50 italic">Queue empty. Watch a video, press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd> to write a note, check "Send to Agent", and save.</p>
{:else}
<div class="space-y-3">
{#each agentItems as item}
<div class="p-4 rounded-xl border" style="background:var(--card-bg);border-color:var(--border)">
<div class="flex items-start space-x-3">
<div class="min-w-0 flex-1">
<h3 class="text-sm font-bold" style="color:var(--text-primary)">{item.title || 'Unknown'}</h3>
<p class="text-[10px] mt-0.5" style="color:var(--accent)">{item.channel_name}</p>
<div class="mt-3 p-2 rounded text-xs leading-relaxed" style="background:var(--bg-tertiary)">
<span class="font-bold text-[10px]" style="color:var(--text-muted)">PROMPT:</span>
<p class="mt-1 whitespace-pre-wrap" style="color:var(--text-primary)">{item.user_prompt}</p>
</div>
<div class="flex items-center space-x-2 mt-2">
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
{item.status}
</span>
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
<a href={item.url} target="_blank" rel="noopener noreferrer"
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--accent);border-color:var(--accent)">▶ Watch</a>
{#if item.status === 'pending'}
<button onclick={() => removeAgentItem(item.id)}
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--text-muted);border-color:var(--border)">Remove</button>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else if selectedVideo}
<div class="p-6 max-w-3xl mx-auto space-y-4">
<!-- Video header -->
<div class="flex items-start space-x-4">
<img src={selectedVideo.thumbnail_url} alt="" class="w-40 h-24 rounded-lg object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 space-y-2">
<span class="text-[10px] px-2 py-0.5 font-bold rounded-full cursor-pointer hover:opacity-80 transition"
style="color:var(--accent);background:var(--bg-tertiary)"
onclick={() => { channelFilter = selectedVideo.channel_name; }}
title="Filter by this channel">{selectedVideo.channel_name}</span>
<h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}
{#if selectedVideo.duration}
{formatDuration(selectedVideo.duration)}
{/if}
{#if selectedVideo.view_count}
{formatViews(selectedVideo.view_count)} views
{/if}
</div>
<div class="flex space-x-2">
<button onclick={() => updateStatus(selectedVideo.video_id, 'read')} class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Read (r)</button>
<button onclick={() => updateStatus(selectedVideo.video_id, 'skipped')} class="text-[10px] px-2.5 py-1 font-bold rounded border transition" style="color:var(--text-secondary);border-color:var(--border)">Skip (s)</button>
<button onclick={() => activeNote = activeNote === selectedVideo.video_id ? null : selectedVideo.video_id}
class="text-[10px] px-2.5 py-1 font-bold rounded border transition"
style="color:var(--text-muted);border-color:var(--border)">✎ Note (n)</button>
<a href={selectedVideo.url} target="_blank" rel="noopener noreferrer" class="text-[10px] px-2.5 py-1 text-white font-bold rounded transition" style="background:var(--accent)">▶ Watch</a>
</div>
</div>
</div>
<!-- Note editor/viewer -->
{#if activeNote === selectedVideo.video_id}
<div class="space-y-1.5">
<textarea id="note-textarea" rows="3" class="w-full text-xs p-2 rounded border resize-none"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
placeholder="Write instructions for the agent here...
e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
></textarea>
<div class="flex items-center space-x-3">
<label class="flex items-center space-x-1.5 text-[11px] cursor-pointer" style="color:var(--text-muted)">
<input type="checkbox" bind:checked={queueToAgent}
class="w-3 h-3 rounded" style="accent-color:var(--accent)">
<span>Send to Agent</span>
</label>
<div class="flex-1"></div>
<button onclick={saveNote}
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
{queueToAgent ? 'Save + 🤖 Queue' : 'Save'}
</button>
</div>
</div>
{:else if notes[selectedVideo.video_id]}
<div class="p-2 rounded border text-xs leading-relaxed cursor-pointer"
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
onclick={() => activeNote = selectedVideo.video_id}
title="Click to edit">{notes[selectedVideo.video_id]}</div>
{/if}
<!-- AI Digest -->
<div class="rounded-xl border p-5 space-y-3 shadow-lg" style="background:var(--card-bg);border-color:var(--accent);border-left-width:3px">
<div class="flex items-center justify-between pb-2" style="border-bottom:1px solid var(--border)">
<h2 class="text-xs font-black tracking-widest uppercase" style="color:var(--accent)">AI Digest</h2>
{#if !selectedVideo.summary || selectedVideo.summary.includes("Error")}
<button onclick={() => generateSummary(selectedVideo.video_id)}
class="text-[11px] px-3 py-1 font-semibold rounded border transition" style="color:var(--accent);border-color:var(--accent)">Generate</button>
{/if}
</div>
<div class="prose max-w-none text-sm leading-relaxed" style="color:var(--text-primary)">
{@html formatMarkdown(selectedVideo.summary)}
</div>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
<p>Select a video to read its summary</p>
</div>
{/if}
</main>
</div>
{#if showHelp}
<div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.6)" onclick={() => showHelp = false}>
<div class="rounded-xl p-6 max-w-xs w-full space-y-3 shadow-2xl" style="background:var(--card-bg);border:1px solid var(--border)" onclick={e => e.stopPropagation()}>
<h3 class="text-sm font-bold" style="color:var(--accent)">Keyboard Shortcuts</h3>
<div class="space-y-1.5 text-xs" style="color:var(--text-secondary)">
<div class="flex justify-between"><span>Navigate</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">j</kbd> <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)"></kbd></span></div>
<div class="flex justify-between"><span>Navigate up</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">k</kbd> <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)"></kbd></span></div>
<div class="flex justify-between"><span>Mark Read</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">r</kbd> / <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Enter</kbd></span></div>
<div class="flex justify-between"><span>Skip</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">s</kbd></span></div>
<div class="flex justify-between"><span>Note</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd></span></div>
<div class="flex justify-between"><span>Save note</span><span><kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Enter</kbd> / <kbd class="px-1.5 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">Esc</kbd></span></div>
</div>
<p class="text-[10px] text-center pt-1" style="color:var(--text-muted)">Press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">?</kbd> to toggle</p>
</div>
</div>
{/if}
</div>