fix: decode HTML entities in titles; add note timestamps; reorder layout — title first, AI digest below

This commit is contained in:
Gan, Jimmy
2026-06-24 02:18:18 +08:00
parent 5bfd72ab3d
commit 1c4f9dc713
2 changed files with 132 additions and 51 deletions
+3 -2
View File
@@ -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", ""),
+129 -49
View File
@@ -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>