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 asyncio
|
||||||
|
import html
|
||||||
import logging
|
import logging
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
@@ -77,8 +78,8 @@ async def fetch_subscriptions():
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
vid,
|
vid,
|
||||||
snippet.get("title", "Untitled"),
|
html.unescape(snippet.get("title", "Untitled")),
|
||||||
snippet.get("channelTitle", "Unknown"),
|
html.unescape(snippet.get("channelTitle", "Unknown")),
|
||||||
snippet.get("channelId", cid),
|
snippet.get("channelId", cid),
|
||||||
f"https://www.youtube.com/watch?v={vid}",
|
f"https://www.youtube.com/watch?v={vid}",
|
||||||
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
|
snippet.get("thumbnails", {}).get("high", {}).get("url", ""),
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isSyncing = $state(false);
|
let isSyncing = $state(false);
|
||||||
let syncMessage = $state('');
|
let syncMessage = $state('');
|
||||||
|
let pendingCount = $state(0);
|
||||||
let theme = $state(localStorage.getItem('theme') || 'dark');
|
let theme = $state(localStorage.getItem('theme') || 'dark');
|
||||||
let summaryPanel = $state(null);
|
let summaryPanel = $state(null);
|
||||||
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
|
let notes = $state(JSON.parse(localStorage.getItem('t-yt-notes') || '{}'));
|
||||||
let activeNote = $state(null);
|
let activeNote = $state(null);
|
||||||
|
let showHelp = $state(false);
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
@@ -59,11 +61,13 @@
|
|||||||
} catch (err) { isSyncing = false; syncMessage = ''; }
|
} 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 }) });
|
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) });
|
||||||
|
if (!silent) {
|
||||||
videos = videos.filter(v => v.video_id !== videoId);
|
videos = videos.filter(v => v.video_id !== videoId);
|
||||||
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function generateSummary(videoId) {
|
async function generateSummary(videoId) {
|
||||||
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
|
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
|
||||||
@@ -75,42 +79,95 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedVideo && summaryPanel) {
|
if (selectedVideo && summaryPanel) {
|
||||||
tick().then(() => summaryPanel.scrollTop = 0);
|
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(() => {
|
onMount(() => {
|
||||||
document.documentElement.className = theme;
|
document.documentElement.className = theme;
|
||||||
loadVideos();
|
loadVideos();
|
||||||
|
loadPendingCount();
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
return () => window.removeEventListener('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) {
|
function handleKeydown(e) {
|
||||||
if (!videos.length) return;
|
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);
|
const idx = videos.findIndex(v => v.video_id === selectedVideo?.video_id);
|
||||||
|
let nextIdx = idx;
|
||||||
if (e.key === 'j' || e.key === 'ArrowDown') {
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const next = idx < videos.length - 1 ? idx + 1 : 0;
|
if (idx < videos.length - 1) nextIdx = idx + 1; else return;
|
||||||
selectedVideo = videos[next];
|
|
||||||
scrollToVideo(next);
|
|
||||||
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const prev = idx > 0 ? idx - 1 : videos.length - 1;
|
if (idx > 0) nextIdx = idx - 1; else return;
|
||||||
selectedVideo = videos[prev];
|
|
||||||
scrollToVideo(prev);
|
|
||||||
} else if (e.key === 'r' || e.key === 'm') {
|
} else if (e.key === 'r' || e.key === 'm') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedVideo) updateStatus(selectedVideo.video_id, 'read');
|
if (selectedVideo) updateStatus(selectedVideo.video_id, 'read');
|
||||||
|
return;
|
||||||
} else if (e.key === 's') {
|
} else if (e.key === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (selectedVideo) updateStatus(selectedVideo.video_id, 'skipped');
|
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) {
|
function scrollToVideo(index) {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = document.querySelector(`[data-video-index="${index}"]`);
|
const el = document.querySelector(`[data-video-index="${index}"]`);
|
||||||
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
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) {
|
function formatMarkdown(text) {
|
||||||
@@ -155,7 +212,7 @@
|
|||||||
<button onclick={() => activeTab = tab}
|
<button onclick={() => activeTab = tab}
|
||||||
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
class="flex-1 py-1.5 text-center text-[11px] font-bold rounded uppercase transition"
|
||||||
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
|
style="color: {activeTab === tab ? 'var(--accent)' : 'var(--text-muted)'}">
|
||||||
{tab}
|
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,7 +221,7 @@
|
|||||||
{@const i = videos.indexOf(video)}
|
{@const i = videos.indexOf(video)}
|
||||||
<button onclick={() => { selectedVideo = video; }}
|
<button onclick={() => { selectedVideo = video; }}
|
||||||
data-video-index={i}
|
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'};
|
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'}">
|
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)" />
|
<img src={video.thumbnail_url} alt="" class="w-20 h-12 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
|
||||||
@@ -182,49 +239,55 @@
|
|||||||
<main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
|
<main bind:this={summaryPanel} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
|
||||||
{#if selectedVideo}
|
{#if selectedVideo}
|
||||||
<div class="p-6 max-w-3xl mx-auto space-y-4">
|
<div class="p-6 max-w-3xl mx-auto space-y-4">
|
||||||
<!-- ★ AI Digest -->
|
<!-- Video header -->
|
||||||
<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-start space-x-4">
|
||||||
<div class="flex items-center justify-between pb-2" style="border-bottom:1px solid var(--border)">
|
<img src={selectedVideo.thumbnail_url} alt="" class="w-40 h-24 rounded-lg object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
|
||||||
<h2 class="text-xs font-black tracking-widest uppercase" style="color:var(--accent)">AI Digest</h2>
|
<div class="min-w-0 space-y-2">
|
||||||
{#if !selectedVideo.summary || selectedVideo.summary.includes("Error")}
|
<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>
|
||||||
<button onclick={() => generateSummary(selectedVideo.video_id)}
|
<h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
|
||||||
class="text-[11px] px-3 py-1 font-semibold rounded border transition" style="color:var(--accent);border-color:var(--accent)">
|
<div class="flex space-x-2">
|
||||||
Generate
|
<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>
|
<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>
|
||||||
{/if}
|
<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 class="prose max-w-none text-sm leading-relaxed" style="color:var(--text-primary)">
|
|
||||||
{@html formatMarkdown(selectedVideo.summary)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video info -->
|
<!-- Note editor/viewer -->
|
||||||
<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}
|
{#if activeNote === selectedVideo.video_id}
|
||||||
<div class="space-y-1.5 mt-2">
|
<div class="space-y-1.5">
|
||||||
<textarea rows="2" class="w-full text-xs p-2 rounded border resize-none"
|
<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)"
|
style="background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border)"
|
||||||
placeholder="Quick note..."
|
placeholder="Quick note..."
|
||||||
value={notes[selectedVideo.video_id] || ''}
|
value={notes[selectedVideo.video_id] || ''}
|
||||||
oninput={e => notes[selectedVideo.video_id] = e.target.value}
|
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; } }}
|
use:autoFocus
|
||||||
|
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
|
||||||
></textarea>
|
></textarea>
|
||||||
<button onclick={() => { localStorage.setItem('t-yt-notes', JSON.stringify(notes)); activeNote = null; }}
|
<button onclick={saveNote}
|
||||||
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">Save</button>
|
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">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}
|
{/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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,4 +298,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user