fix: decode HTML entities in titles; add note timestamps; reorder layout — title first, AI digest below
This commit is contained in:
+3
-2
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import aiosqlite
|
||||
from googleapiclient.discovery import build
|
||||
@@ -77,8 +78,8 @@ async def fetch_subscriptions():
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
vid,
|
||||
snippet.get("title", "Untitled"),
|
||||
snippet.get("channelTitle", "Unknown"),
|
||||
html.unescape(snippet.get("title", "Untitled")),
|
||||
html.unescape(snippet.get("channelTitle", "Unknown")),
|
||||
snippet.get("channelId", cid),
|
||||
f"https://www.youtube.com/watch?v={vid}",
|
||||
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
let isLoading = $state(false);
|
||||
let isSyncing = $state(false);
|
||||
let syncMessage = $state('');
|
||||
let pendingCount = $state(0);
|
||||
let theme = $state(localStorage.getItem('theme') || 'dark');
|
||||
let summaryPanel = $state(null);
|
||||
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
|
||||
let activeNote = $state(null);
|
||||
let showHelp = $state(false);
|
||||
|
||||
function toggleTheme() {
|
||||
theme = theme === 'dark' ? 'light' : 'dark';
|
||||
@@ -59,10 +61,12 @@
|
||||
} catch (err) { isSyncing = false; syncMessage = ''; }
|
||||
}
|
||||
|
||||
async function updateStatus(videoId, newStatus) {
|
||||
async function updateStatus(videoId, newStatus, silent = false) {
|
||||
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;
|
||||
if (!silent) {
|
||||
videos = videos.filter(v => v.video_id !== videoId);
|
||||
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSummary(videoId) {
|
||||
@@ -75,42 +79,95 @@
|
||||
$effect(() => {
|
||||
if (selectedVideo && summaryPanel) {
|
||||
tick().then(() => summaryPanel.scrollTop = 0);
|
||||
// Auto-mark as read after 3s of viewing a summarized video
|
||||
const vid = selectedVideo.video_id;
|
||||
const hasSummary = selectedVideo.summary && !selectedVideo.summary.includes('Error') && !selectedVideo.summary.includes('📺 Short clip');
|
||||
if (hasSummary && activeTab === 'pending') {
|
||||
const timer = setTimeout(() => updateStatus(vid, 'read', true), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
document.documentElement.className = theme;
|
||||
loadVideos();
|
||||
loadPendingCount();
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
async function loadPendingCount() {
|
||||
try {
|
||||
const res = await fetch('/api/videos?status=pending&limit=1');
|
||||
if (res.ok) {
|
||||
// Count from header or estimate — just ping for now
|
||||
const all = await fetch('/api/videos?status=pending&limit=200');
|
||||
if (all.ok) pendingCount = (await all.json()).length;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (!videos.length) return;
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
|
||||
const idx = videos.findIndex(v => v.video_id === selectedVideo?.video_id);
|
||||
let nextIdx = idx;
|
||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = idx < videos.length - 1 ? idx + 1 : 0;
|
||||
selectedVideo = videos[next];
|
||||
scrollToVideo(next);
|
||||
if (idx < videos.length - 1) nextIdx = idx + 1; else return;
|
||||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = idx > 0 ? idx - 1 : videos.length - 1;
|
||||
selectedVideo = videos[prev];
|
||||
scrollToVideo(prev);
|
||||
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) {
|
||||
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
|
||||
activeNote = null;
|
||||
} else if (selectedVideo) {
|
||||
updateStatus(selectedVideo.video_id, 'read');
|
||||
}
|
||||
return;
|
||||
} else if (e.key === '?') {
|
||||
e.preventDefault();
|
||||
showHelp = !showHelp;
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
selectedVideo = videos[nextIdx];
|
||||
scrollToVideo(nextIdx);
|
||||
}
|
||||
|
||||
function scrollToVideo(index) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-video-index="${index}"]`);
|
||||
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
const el = document.querySelector(`[data-video-index="${index}"]`);
|
||||
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function autoFocus(node) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
const vid = selectedVideo?.video_id;
|
||||
if (!vid) return;
|
||||
const existing = notes[vid] || '';
|
||||
if (existing && !existing.startsWith('[')) {
|
||||
notes[vid] = `[${new Date().toLocaleString()}] ${existing}`;
|
||||
}
|
||||
localStorage.setItem('t-yt-notes', JSON.stringify(notes));
|
||||
activeNote = null;
|
||||
}
|
||||
|
||||
function formatMarkdown(text) {
|
||||
@@ -155,7 +212,7 @@
|
||||
<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}
|
||||
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -164,7 +221,7 @@
|
||||
{@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"
|
||||
class="w-full text-left p-3 transition flex items-start space-x-3 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'}">
|
||||
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
|
||||
@@ -182,51 +239,57 @@
|
||||
<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 -->
|
||||
<!-- 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" style="color:var(--accent);background:var(--bg-tertiary)">{selectedVideo.channel_name}</span>
|
||||
<h1 class="text-lg 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 (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 rows="2" 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="Quick note..."
|
||||
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>
|
||||
<button onclick={saveNote}
|
||||
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
|
||||
</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>
|
||||
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>
|
||||
<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</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>
|
||||
{#if activeNote === selectedVideo.video_id}
|
||||
<div class="space-y-1.5 mt-2">
|
||||
<textarea rows="2" 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="Quick note..."
|
||||
value={notes[selectedVideo.video_id] || ''}
|
||||
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
||||
onkeydown={e => { if (e.key === 'Escape') { localStorage.setItem('t-yt-notes', JSON.stringify(notes)); activeNote = null; } }}
|
||||
></textarea>
|
||||
<button onclick={() => { localStorage.setItem('t-yt-notes', JSON.stringify(notes)); activeNote = null; }}
|
||||
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
|
||||
@@ -235,4 +298,21 @@
|
||||
{/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>
|
||||
|
||||
Reference in New Issue
Block a user