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

210 lines
9.7 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 theme = $state(localStorage.getItem('theme') || 'dark');
let summaryPanel = $state(null);
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.className = theme;
}
async function loadVideos() {
isLoading = true;
try {
const res = await fetch(`/api/videos?status=${activeTab}&limit=50`);
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' });
if (res.ok) setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
} catch (err) { isSyncing = false; syncMessage = ''; }
}
async function updateStatus(videoId, newStatus) {
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) });
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
}
async function generateSummary(videoId) {
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST' });
setTimeout(() => loadVideos(), 3000);
}
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
if (selectedVideo && summaryPanel) {
tick().then(() => summaryPanel.scrollTop = 0);
}
});
onMount(() => {
document.documentElement.className = theme;
loadVideos();
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function handleKeydown(e) {
if (!videos.length) return;
const idx = videos.findIndex(v => v.video_id === selectedVideo?.video_id);
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < videos.length - 1 ? idx + 1 : 0;
selectedVideo = videos[next];
scrollToVideo(next);
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = idx > 0 ? idx - 1 : videos.length - 1;
selectedVideo = videos[prev];
scrollToVideo(prev);
} else if (e.key === 'r' || e.key === 'm') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'read');
} else if (e.key === 's') {
e.preventDefault();
if (selectedVideo) updateStatus(selectedVideo.video_id, 'skipped');
}
}
function scrollToVideo(index) {
requestAnimationFrame(() => {
const el = document.querySelector(`[data-video-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
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;
}
</script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
<!-- Top bar -->
<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>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Left: video list -->
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped'] 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}
</button>
{/each}
</div>
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
{#each videos as video (video.video_id)}
{@const i = videos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3"
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'}">
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0">
<span class="block text-[10px] font-semibold truncate" style="color:var(--accent)">{video.channel_name}</span>
<h3 class="text-xs font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<span class="text-[10px] mt-0.5 block" style="color:var(--text-muted)">{video.publish_date || ''}</span>
</div>
</button>
{/each}
</div>
</aside>
<!-- Right: summary PRIMARY -->
<main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if selectedVideo}
<div class="p-6 max-w-3xl mx-auto space-y-4">
<!-- ★ 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") || selectedVideo.summary.includes("Could not")}
<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>
<!-- Video info -->
<div class="flex items-start space-x-3">
<img src={selectedVideo.thumbnail_url} alt="" class="w-32 h-18 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 space-y-1.5">
<span class="text-[10px] px-2 py-0.5 font-bold rounded-full" style="color:var(--accent);background:var(--bg-tertiary)">{selectedVideo.channel_name}</span>
<h1 class="text-sm font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<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</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</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>
</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>
</div>