fix: skip auth token when Authelia authed + frontend search bar improvements

This commit is contained in:
Gan, Jimmy
2026-07-09 22:53:06 +08:00
parent 90a47c95c3
commit 5c767190d2
3 changed files with 275 additions and 87 deletions
+257 -71
View File
@@ -1,6 +1,6 @@
<script>
import { onMount, tick } from 'svelte';
let videos = $state([]);
let selectedVideo = $state(null);
let activeTab = $state('pending');
@@ -21,13 +21,37 @@
let allChannels = $state([]);
let editingCategories = $state(false);
let prevSelectedId = $state(null);
// Search state
let searchQuery = $state('');
let searchTimeout = null;
let searchResults = $state([]);
let isSearching = $state(false);
let showSearchResults = $state(false);
// Batch select state
let selectedVideoIds = $state(new Set());
let showBatchBar = $state(false);
// Stats state
let statsData = $state(null);
let showStats = $state(false);
// Agent queue state
let agentItems = $state([]);
let agentLoading = $state(false);
// Stats fetcher
async function loadStats() {
try {
const res = await fetch('/api/stats');
if (res.ok) statsData = await res.json();
} catch(e) {}
}
$effect(() => {
if (activeTab === 'stats' && !statsData) loadStats();
});
$effect(() => {
if (activeTab === 'pending' && channelFilter) {
loadVideos();
@@ -35,12 +59,12 @@
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"};
let categories = $state({...AUTO_CATEGORIES});
let channels = $derived([...new Set(videos.map(v => v.channel_name))].sort());
let filteredVideos = $derived(
channelFilter
@@ -50,15 +74,92 @@
: videos
);
let catList = $derived([...new Set(Object.values(categories).flat())].sort());
let noteIds = $derived(new Set(Object.keys(notes).filter(k => notes[k] && notes[k].trim())));
// Search debouncer
let searchTimeout = null;
async function handleSearch(query) {
searchQuery = query;
if (!query || query.length < 2) {
showSearchResults = false;
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;
}
} catch(e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}
// Batch operations
function toggleSelect(videoId) {
const newSet = new Set(selectedVideoIds);
if (newSet.has(videoId)) {
newSet.delete(videoId);
} else {
newSet.add(videoId);
}
selectedVideoIds = newSet;
showBatchBar = newSet.size > 0;
}
function selectAll() {
selectedVideoIds = new Set(filteredVideos.map(v => v.video_id));
showBatchBar = true;
}
function clearSelection() {
selectedVideoIds = new Set();
showBatchBar = false;
}
async function batchUpdate(status) {
if (selectedVideoIds.size === 0) return;
const ids = Array.from(selectedVideoIds);
try {
const res = await fetch('/api/videos/batch', {
method: 'POST',
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ video_ids: ids, status })
});
if (res.ok) {
// Remove selected from local list
videos = videos.filter(v => !ids.includes(v.video_id));
clearSelection();
loadPendingCount();
}
} catch(e) {
console.error('Batch update failed:', e);
}
}
function formatDuration(seconds) {
if (!seconds || seconds < 60) return '';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatViews(count) {
if (!count || count < 1000) return count || '';
if (count < 1000000) return `${(count / 1000).toFixed(1)}K`;
return `${(count / 1000000).toFixed(1)}M`;
}
function toggleTheme() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.className = theme;
}
async function loadVideos() {
isLoading = true;
try {
@@ -76,13 +177,6 @@
if (channelFilter && !channelFilter.startsWith('📁')) {
params.set('channel', channelFilter);
}
if (searchQuery.trim()) {
params.set('search', searchQuery.trim());
}
if (searchQuery.trim()) {
params.delete('status');
params.set('limit', '50');
}
const res = await fetch(`/api/videos?${params}`);
if (res.ok) {
videos = await res.json();
@@ -101,7 +195,7 @@
isLoading = false;
}
}
async function syncSubscriptions() {
isSyncing = true;
syncMessage = 'Syncing...';
@@ -116,7 +210,7 @@
}
} catch (err) { isSyncing = false; syncMessage = 'Network error'; }
}
async function updateStatus(videoId, newStatus, silent = false) {
await fetch(`/api/videos/${videoId}/status`, { method: 'POST', headers: getAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ status: newStatus }) });
if (!silent) {
@@ -125,15 +219,15 @@
if (videos.length < 20) loadVideos();
}
}
async function generateSummary(videoId) {
if (selectedVideo?.video_id === videoId) selectedVideo.summary = "Generating...";
await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST', headers: getAuthHeaders() });
setTimeout(() => loadVideos(), 3000);
}
// ---- Server-side notes ----
async function loadNotes() {
try {
const res = await fetch('/api/notes');
@@ -145,7 +239,7 @@
}
} catch(e) { console.error('Failed to load notes:', e); }
}
async function migrateLocalNotes() {
try {
const local = JSON.parse(localStorage.getItem('t-yt-notes') || '{}');
@@ -163,7 +257,7 @@
await loadNotes();
} catch(e) {}
}
async function saveNote() {
const vid = selectedVideo?.video_id;
if (!vid) return;
@@ -181,9 +275,9 @@
activeNote = null;
queueToAgent = false;
}
// ---- Agent queue ----
async function loadAgentQueue() {
agentLoading = true;
try {
@@ -192,12 +286,12 @@
} catch(e) { console.error('Failed to load agent queue:', e); }
finally { agentLoading = false; }
}
async function removeAgentItem(id) {
await fetch(`/api/agent-queue/${id}`, { method: 'DELETE', headers: getAuthHeaders() });
agentItems = agentItems.filter(i => i.id !== id);
}
async function initAuth() {
// Check if auth is enabled on the backend
try {
@@ -213,7 +307,7 @@
showTokenSetup = false;
return true;
}
async function setupToken() {
if (apiTokenInput.trim()) {
apiToken = apiTokenInput.trim();
@@ -221,7 +315,7 @@
showTokenSetup = false;
}
}
function getAuthHeaders(extraHeaders = {}) {
const headers = { ...extraHeaders };
if (apiToken) {
@@ -229,9 +323,9 @@
}
return headers;
}
// ---- Lifecycle ----
$effect(() => { if (activeTab) loadVideos(); });
$effect(() => {
// Scroll to top when switching videos
@@ -243,7 +337,7 @@
// NOTE: auto-mark-read is now handled by scroll-to-bottom detection (see below)
// and explicit user actions (r/Enter key or Read button).
});
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
let scrollReadTimer = $state(null);
function handleSummaryScroll() {
@@ -264,7 +358,7 @@
$effect(() => {
if (activeTab === 'agent') loadAgentQueue();
});
onMount(async () => {
document.documentElement.className = theme;
await initAuth();
@@ -279,7 +373,7 @@
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
function updateSyncTimer() {
const el = document.getElementById('sync-timer');
if (!el) return;
@@ -293,7 +387,7 @@
el.textContent = `Next sync in ${h}h ${m}m`;
setTimeout(updateSyncTimer, 60000);
}
async function loadPendingCount() {
try {
const res = await fetch('/api/videos?status=pending&limit=201');
@@ -303,14 +397,14 @@
}
} catch(e) {}
}
async function loadChannels() {
try {
const res = await fetch('/api/channels');
if (res.ok) allChannels = await res.json();
} catch(e) {}
}
function handleKeydown(e) {
if (!videos.length) return;
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
@@ -354,16 +448,16 @@
selectedVideo = filteredVideos[nextIdx];
scrollToVideo(nextIdx);
}
function scrollToVideo(index) {
const el = document.querySelector(`[data-video-index="${index}"]`);
if (el) el.scrollIntoView({ block: 'nearest', behavior: 'instant' });
}
function autoFocus(node) {
requestAnimationFrame(() => node.focus());
}
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>');
@@ -375,16 +469,23 @@
}).join('');
return html;
}
function noteWordCount(noteText) {
if (!noteText) return 0;
return noteText.trim().split(/\s+/).length;
}
function formatDate(d) {
if (!d) return '';
return d.slice(0, 10);
}
// Stats display helpers
function formatStatsNumber(n) {
if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n/1000).toFixed(1)}K`;
return n.toString();
}
</script>
<div style="background:var(--bg-primary);color:var(--text-primary);height:100vh;overflow:hidden;display:flex;flex-direction:column">
@@ -440,15 +541,34 @@
<aside style="width:280px;background:var(--bg-secondary);border-right:1px solid var(--border)" class="flex flex-col flex-shrink-0">
<!-- Tabs -->
<div class="flex p-1.5" style="border-bottom:1px solid var(--border)">
{#each ['pending','read','skipped','notes','🤖 Agent'] as tab}
{#each ['pending','read','skipped','notes','stats','🤖 Agent'] 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 === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab}
{tab === 'pending' && pendingCount ? `Pending (${pendingCount})` : tab === 'stats' ? '📊 Stats' : tab === '🤖 Agent' ? '🤖 Agent' : tab}
</button>
{/each}
</div>
<!-- Search bar (shown in pending/read/skipped tabs) -->
{#if activeTab !== '🤖 Agent' && activeTab !== 'stats' && activeTab !== 'notes'}
<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}
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)}>
{#if isSearching}
<div class="absolute right-2 top-1.5 text-[9px] animate-spin" style="color:var(--accent)"></div>
{/if}
</div>
</div>
{/if}
<!-- Agent Queue tab content -->
{#if activeTab === '🤖 Agent'}
<div class="flex-1 overflow-y-auto" style="border-color:var(--border)">
@@ -486,26 +606,56 @@
{/each}
{/if}
</div>
{:else}
<!-- Search bar -->
<div class="px-2 py-1.5" style="border-bottom:1px solid var(--border)">
<div class="flex items-center space-x-1" style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;padding:2px 8px">
<span style="color:var(--text-muted);font-size:12px">🔍</span>
<input type="text" bind:value={searchQuery}
placeholder="Search videos..."
class="w-full text-[11px] py-1 bg-transparent outline-none"
style="color:var(--text-primary)"
oninput={() => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadVideos, 300);
}}
onkeydown={(e) => { if (e.key === 'Enter') { clearTimeout(searchTimeout); loadVideos(); } }} />
{#if searchQuery}
<button onclick={() => { searchQuery = ''; loadVideos(); }}
class="text-[10px] px-1" style="color:var(--text-muted)"></button>
{/if}
</div>
{:else if activeTab === 'stats'}
<!-- Stats Dashboard -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
{#if !statsData}
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading stats...</div>
{:else}
<!-- Summary cards -->
<div class="grid grid-cols-2 gap-2">
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{formatStatsNumber(statsData.total || 0)}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Total Videos</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.summary_rate || 0}%</div>
<div class="text-[9px]" style="color:var(--text-muted)">Summary Rate</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.channels || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Channels</div>
</div>
<div class="p-2 rounded border text-center" style="background:var(--bg-tertiary);border-color:var(--border)">
<div class="text-lg font-black" style="color:var(--accent)">{statsData.last_7_days || 0}</div>
<div class="text-[9px]" style="color:var(--text-muted)">Last 7 Days</div>
</div>
</div>
<!-- Status breakdown -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">STATUS BREAKDOWN</div>
{#each Object.entries(statsData.status_breakdown || {}) as [status, count]}
<div class="flex justify-between items-center py-1">
<span class="text-[11px]" style="color:var(--text-primary)">{status}</span>
<span class="text-[11px] font-bold" style="color:var(--accent)">{count}</span>
</div>
{/each}
</div>
<!-- Top channels -->
<div class="p-2 rounded border" style="border-color:var(--border)">
<div class="text-[10px] font-bold mb-2" style="color:var(--text-muted)">TOP CHANNELS</div>
{#each (statsData.top_channels || []).slice(0, 8) as ch}
<div class="flex justify-between items-center py-1">
<span class="text-[10px] truncate flex-1 mr-2" style="color:var(--text-primary)">{ch.name}</span>
<span class="text-[10px]" style="color:var(--text-muted)">{ch.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Channel filter bar -->
{#if (allChannels.length || channels.length) > 1}
<div class="px-2 py-1.5 space-y-1" style="border-bottom:1px solid var(--border)">
@@ -571,14 +721,31 @@
{@const i = filteredVideos.indexOf(video)}
<button onclick={() => { selectedVideo = video; }}
data-video-index={i}
class="w-full text-left p-3 transition flex items-start space-x-3 outline-none focus:outline-none"
class="w-full text-left p-2.5 transition flex items-start space-x-2 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)" />
<div class="min-w-0">
<!-- Batch select checkbox -->
<input type="checkbox"
checked={selectedVideoIds.has(video.video_id)}
onclick={(e) => { e.stopPropagation(); toggleSelect(video.video_id); }}
class="w-3 h-3 mt-1 rounded flex-shrink-0"
style="accent-color:var(--accent)">
<img src={video.thumbnail_url} alt="" class="w-16 h-10 rounded object-cover flex-shrink-0" style="background:var(--bg-tertiary)" />
<div class="min-w-0 flex-1">
<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 || ''} {noteIds.has(video.video_id) ? '📝' : ''}</span>
<h3 class="text-[11px] font-bold line-clamp-2 leading-snug" style="color:var(--text-primary)">{video.title}</h3>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[9px]" style="color:var(--text-muted)">{video.publish_date || ''}</span>
{#if video.duration}
<span class="text-[9px]" style="color:var(--text-muted)">{formatDuration(video.duration)}</span>
{/if}
{#if video.view_count && video.view_count > 0}
<span class="text-[9px]" style="color:var(--text-muted)">{formatViews(video.view_count)}</span>
{/if}
{#if noteIds.has(video.video_id)}
<span class="text-[9px]">📝</span>
{/if}
</div>
</div>
</button>
{/each}
@@ -586,6 +753,17 @@
{/if}
</aside>
<!-- Batch action bar -->
{#if showBatchBar}
<div class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-40 px-4 py-2 rounded-full shadow-lg flex items-center space-x-3"
style="background:var(--bg-secondary);border:1px solid var(--border)">
<span class="text-xs font-bold" style="color:var(--accent)">{selectedVideoIds.size} selected</span>
<button onclick={() => batchUpdate('read')} class="text-[10px] px-3 py-1 text-white font-bold rounded" style="background:var(--accent)">Read All</button>
<button onclick={() => batchUpdate('skipped')} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-secondary);border-color:var(--border)">Skip All</button>
<button onclick={clearSelection} class="text-[10px] px-3 py-1 font-bold rounded border" style="color:var(--text-muted);border-color:var(--border)">Clear</button>
</div>
{/if}
<!-- Right: summary panel -->
<main bind:this={summaryPanel} onscroll={handleSummaryScroll} id="summary-panel" class="flex-1 overflow-y-auto" style="background:var(--bg-primary);min-height:0">
{#if activeTab === '🤖 Agent'}
@@ -636,7 +814,14 @@
onclick={() => { channelFilter = selectedVideo.channel_name; }}
title="Filter by this channel">{selectedVideo.channel_name}</span>
<h1 class="text-lg font-bold leading-snug" style="color:var(--text-primary)">{selectedVideo.title}</h1>
<div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}</div>
<div class="text-[10px]" style="color:var(--text-muted)">{selectedVideo.publish_date || ''}
{#if selectedVideo.duration}
{formatDuration(selectedVideo.duration)}
{/if}
{#if selectedVideo.view_count}
{formatViews(selectedVideo.view_count)} views
{/if}
</div>
<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>
@@ -653,7 +838,8 @@
<div class="space-y-1.5">
<textarea id="note-textarea" rows="3" 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="Write instructions for the agent here...&#10;e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
placeholder="Write instructions for the agent here...
e.g. 'Add this gyroid lattice workflow as a Hermes skill'"
value={notes[selectedVideo.video_id] || ''}
oninput={e => notes[selectedVideo.video_id] = e.target.value}
use:autoFocus