210 lines
9.7 KiB
Svelte
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>
|