Sprint 002: Ingest duration & view count, FTS search, infinite scroll pagination, and transcript accordion
Deploy t-youtube / Build & Deploy (push) Failing after 1s
Deploy t-youtube / Build & Deploy (push) Failing after 1s
This commit is contained in:
@@ -24,6 +24,10 @@
|
||||
|
||||
// Search state
|
||||
let searchQuery = $state('');
|
||||
let page = $state(0);
|
||||
let hasMore = $state(true);
|
||||
let isMoreLoading = $state(false);
|
||||
let showTranscript = $state(false);
|
||||
let searchResults = $state([]);
|
||||
let isSearching = $state(false);
|
||||
let showSearchResults = $state(false);
|
||||
@@ -52,13 +56,7 @@
|
||||
if (activeTab === 'stats' && !statsData) loadStats();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (activeTab === 'pending' && channelFilter) {
|
||||
loadVideos();
|
||||
} else if (activeTab === 'pending' && !channelFilter) {
|
||||
loadVideos();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Auto-generated categories (AI-categorized)
|
||||
const AUTO_CATEGORIES = {"AI Engineer":"AI & Tech","AI-Fan AI研究室-帆哥":"AI & Tech","AI大模型":"AI & Tech","AI探索与发现":"AI & Tech","Aurora Tech":"AI & Tech","Claude":"AI & Tech","ColdFusion":"AI & Tech","Data Is Beautiful":"AI & Tech","DeepLearningAI":"AI & Tech","Google":"AI & Tech","Google Cloud Tech":"AI & Tech","Lex Fridman":"AI & Tech","NiceKate AI":"AI & Tech","PC Sim":"AI & Tech","Stanford Online":"AI & Tech","The Linux Foundation":"AI & Tech","回到Axton":"AI & Tech","回形针PaperClip":"AI & Tech","林亦LYi":"AI & Tech","硅谷101播客":"AI & Tech","老石谈芯":"AI & Tech","鲲鹏Talk":"AI & Tech","3D Beast":"3D & Design","Arrimus3D Modeling and Design":"3D & Design","Blender":"3D & Design","Blender Guru":"3D & Design","Creators 3D":"3D & Design","Designfusion":"3D & Design","Foundry":"3D & Design","Grant Abbitt (Gabbitt)":"3D & Design","Gravity Sketch":"3D & Design","Hyperganic":"3D & Design","MSLattice":"3D & Design","Manolo Remiddi":"3D & Design","Rhino 3D (Rhinoceros3d official)":"3D & Design","Shaper3d":"3D & Design","nTop":"3D & Design","spherene AG":"3D & Design","魔界造物":"3D & Design","Chuck Severance":"Programming","Coursera":"Programming","freeCodeCamp.org":"Programming","Kevin Stratvert":"Programming","Leila Gharani":"Programming","LinkedIn Learning":"Programming","Metabase":"Programming","n8n":"Programming","The Coding Gopher":"Programming","Brian Casel":"Programming","code秘密花园":"Programming","cs tech":"Programming","fequalsf":"Programming","openscreencast":"Programming","技术爬爬虾 TechShrimp":"Programming","林本兔Limbuntu":"Programming","Best Choice Review":"Hardware & Reviews","Marques Brownlee":"Hardware & Reviews","TESTV":"Hardware & Reviews","先看评测":"Hardware & Reviews","极客湾Geekerwan":"Hardware & Reviews","硬核拆解":"Hardware & Reviews","笔吧评测室":"Hardware & Reviews","差评硬件部":"Hardware & Reviews","充电头网":"Hardware & Reviews","Apple Developer":"Apple Dev","Made by Google":"Apple Dev","Allen的分享":"Apple Dev","Sypnotix":"Apple Dev","8K World":"Photography","Can Fiona":"Photography","小天fotos":"Photography","影像极客Fotogeeker":"Photography","摄影师PHiL":"Photography","虚空光影CosmosFilm":"Photography","路易斯 LouisDrone":"Photography","Bruno Mars":"Music","GraceLeeMusic":"Music","Katie Melua":"Music","The Weeknd":"Music","TheWeekndVEVO":"Music","杰威爾歌詞MV頻道JVR Lyric MV":"Music","薛汀哲":"Music","Salah Trainer":"Sports","Skills N Talents (swimming)":"Sports","TotalSports TV":"Sports","WorldSBK":"Sports","Everyday Cycling":"Sports","爱羽客羽毛球网":"Sports","adidas":"Sports","Sneaks & Feet极客鞋谈":"Automotive","TOP的遥控玩具":"Automotive","悟空的日常":"Automotive"};
|
||||
@@ -67,11 +65,13 @@
|
||||
|
||||
let channels = $derived([...new Set(videos.map(v => v.channel_name))].sort());
|
||||
let filteredVideos = $derived(
|
||||
channelFilter
|
||||
? channelFilter.startsWith('📁')
|
||||
? videos.filter(v => (categories[v.channel_name] || '') === channelFilter.replace('📁 ', ''))
|
||||
: videos.filter(v => v.channel_name === channelFilter)
|
||||
: videos
|
||||
searchQuery.trim().length >= 2
|
||||
? searchResults
|
||||
: channelFilter
|
||||
? channelFilter.startsWith('📁')
|
||||
? videos.filter(v => (categories[v.channel_name] || '') === channelFilter.replace('📁 ', ''))
|
||||
: videos.filter(v => v.channel_name === channelFilter)
|
||||
: videos
|
||||
);
|
||||
let catList = $derived([...new Set(Object.values(categories).flat())].sort());
|
||||
|
||||
@@ -81,21 +81,39 @@
|
||||
let searchTimeout = null;
|
||||
async function handleSearch(query) {
|
||||
searchQuery = query;
|
||||
if (!query || query.length < 2) {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
if (!query || query.trim().length < 2) {
|
||||
showSearchResults = false;
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
isSearching = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
|
||||
if (res.ok) {
|
||||
searchResults = await res.json();
|
||||
showSearchResults = true;
|
||||
searchTimeout = setTimeout(async () => {
|
||||
isSearching = true;
|
||||
try {
|
||||
const res = await fetch(`/api/videos/search?q=${encodeURIComponent(query)}&limit=50`);
|
||||
if (res.ok) {
|
||||
searchResults = await res.json();
|
||||
showSearchResults = true;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function handleSidebarScroll(e) {
|
||||
if (searchQuery.trim().length >= 2) return;
|
||||
if (isLoading || isMoreLoading || !hasMore) return;
|
||||
const container = e.target;
|
||||
const threshold = 100;
|
||||
const atBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < threshold;
|
||||
if (atBottom) {
|
||||
isMoreLoading = true;
|
||||
page += 1;
|
||||
await loadVideos(true);
|
||||
isMoreLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,33 +178,52 @@
|
||||
document.documentElement.className = theme;
|
||||
}
|
||||
|
||||
async function loadVideos() {
|
||||
isLoading = true;
|
||||
async function loadVideos(isAppend = false) {
|
||||
if (!isAppend) {
|
||||
page = 0;
|
||||
hasMore = true;
|
||||
isLoading = true;
|
||||
}
|
||||
try {
|
||||
const limit = 50;
|
||||
const offset = page * limit;
|
||||
|
||||
if (activeTab === 'notes') {
|
||||
// Notes tab: show only pending videos that have notes
|
||||
const res = await fetch(`/api/videos?status=pending&limit=50`);
|
||||
const params = new URLSearchParams({ status: 'pending', limit: limit.toString(), offset: offset.toString() });
|
||||
const res = await fetch(`/api/videos?${params}`);
|
||||
if (res.ok) {
|
||||
const all = await res.json();
|
||||
videos = all.filter(v => noteIds.has(v.video_id));
|
||||
if (videos.length > 0) selectedVideo = videos[0];
|
||||
else selectedVideo = null;
|
||||
const fetched = await res.json();
|
||||
const filtered = fetched.filter(v => noteIds.has(v.video_id));
|
||||
if (isAppend) {
|
||||
videos = [...videos, ...filtered];
|
||||
} else {
|
||||
videos = filtered;
|
||||
if (videos.length > 0) selectedVideo = videos[0];
|
||||
else selectedVideo = null;
|
||||
}
|
||||
if (fetched.length < limit) hasMore = false;
|
||||
}
|
||||
} else {
|
||||
const params = new URLSearchParams({status: activeTab, limit: '200'});
|
||||
} else if (activeTab !== 'stats' && activeTab !== '🤖 Agent') {
|
||||
const params = new URLSearchParams({ status: activeTab, limit: limit.toString(), offset: offset.toString() });
|
||||
if (channelFilter && !channelFilter.startsWith('📁')) {
|
||||
params.set('channel', channelFilter);
|
||||
}
|
||||
const res = await fetch(`/api/videos?${params}`);
|
||||
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];
|
||||
const fetched = await res.json();
|
||||
if (isAppend) {
|
||||
videos = [...videos, ...fetched];
|
||||
} else {
|
||||
selectedVideo = null;
|
||||
videos = fetched;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (fetched.length < limit) hasMore = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -326,7 +363,17 @@
|
||||
|
||||
// ---- Lifecycle ----
|
||||
|
||||
$effect(() => { if (activeTab) loadVideos(); });
|
||||
|
||||
$effect(() => {
|
||||
if (activeTab && activeTab !== 'stats' && activeTab !== '🤖 Agent') {
|
||||
// Clear search when changing tab or filter
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
showSearchResults = false;
|
||||
loadVideos(false);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Scroll to top when switching videos
|
||||
if (selectedVideo && summaryPanel) {
|
||||
@@ -334,8 +381,7 @@
|
||||
}
|
||||
const prevId = prevSelectedId;
|
||||
prevSelectedId = selectedVideo?.video_id || null;
|
||||
// NOTE: auto-mark-read is now handled by scroll-to-bottom detection (see below)
|
||||
// and explicit user actions (r/Enter key or Read button).
|
||||
showTranscript = false; // Reset accordion on video switch
|
||||
});
|
||||
|
||||
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
|
||||
@@ -555,15 +601,17 @@
|
||||
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
value={searchQuery}
|
||||
oninput={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search videos..."
|
||||
class="w-full text-[11px] p-1.5 rounded border"
|
||||
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
|
||||
onfocus={() => { if (searchResults.length) showSearchResults = true; }}
|
||||
onblur={() => setTimeout(() => showSearchResults = false, 200)}>
|
||||
class="w-full text-[11px] p-1.5 pr-8 rounded border"
|
||||
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)">
|
||||
{#if searchQuery}
|
||||
<button onclick={() => { searchQuery = ''; searchResults = []; showSearchResults = false; }}
|
||||
class="absolute right-7 top-2 text-[10px] text-gray-500 hover:text-white transition">✕</button>
|
||||
{/if}
|
||||
{#if isSearching}
|
||||
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)">↻</div>
|
||||
<div class="absolute right-2 top-2 text-[9px] animate-spin" style="color:var(--accent)">↻</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,7 +764,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Video list -->
|
||||
<div class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
|
||||
<div onscroll={handleSidebarScroll} class="flex-1 overflow-y-auto divide-y" style="border-color:var(--border)">
|
||||
{#each filteredVideos as video (video.video_id)}
|
||||
{@const i = filteredVideos.indexOf(video)}
|
||||
<button onclick={() => { selectedVideo = video; }}
|
||||
@@ -749,7 +797,10 @@
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if isMoreLoading}
|
||||
<div class="p-3 text-center text-[10px]" style="color:var(--text-muted)">Loading more...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -878,6 +929,21 @@ e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
|
||||
{@html formatMarkdown(selectedVideo.summary)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcript Accordion -->
|
||||
{#if selectedVideo.transcript && selectedVideo.transcript !== "No transcript available"}
|
||||
<div class="rounded-xl border p-5 space-y-3 shadow-lg" style="background:var(--card-bg);border-color:var(--border)">
|
||||
<div class="flex items-center justify-between cursor-pointer select-none" onclick={() => showTranscript = !showTranscript}>
|
||||
<h2 class="text-xs font-black tracking-widest uppercase" style="color:var(--text-muted)">Video Transcript</h2>
|
||||
<span class="text-xs transition-transform duration-200" style="transform: rotate({showTranscript ? '90deg' : '0deg'})">▶</span>
|
||||
</div>
|
||||
{#if showTranscript}
|
||||
<div class="prose max-w-none text-xs leading-relaxed overflow-y-auto max-h-96 whitespace-pre-wrap pt-2 border-t" style="color:var(--text-secondary);border-color:var(--border)">
|
||||
{selectedVideo.transcript}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
|
||||
|
||||
Reference in New Issue
Block a user