907 lines
44 KiB
Svelte
907 lines
44 KiB
Svelte
<script>
|
|
import { onMount, tick } from 'svelte';
|
|
|
|
let videos = $state([]);
|
|
let selectedVideo = $state(null);
|
|
let activeTab = $state('pending');
|
|
let isLoading = $state(false);
|
|
let isSyncing = $state(false);
|
|
let syncMessage = $state('');
|
|
let pendingCount = $state(0);
|
|
let theme = $state(localStorage.getItem('theme') || 'dark');
|
|
let apiToken = $state(localStorage.getItem('t-yt-token') || '');
|
|
let apiTokenInput = $state('');
|
|
let showTokenSetup = $state(false);
|
|
let summaryPanel = $state(null);
|
|
let notes = $state({});
|
|
let activeNote = $state(null);
|
|
let queueToAgent = $state(false);
|
|
let showHelp = $state(false);
|
|
let channelFilter = $state('');
|
|
let allChannels = $state([]);
|
|
let editingCategories = $state(false);
|
|
let prevSelectedId = $state(null);
|
|
|
|
// Search state
|
|
let searchQuery = $state('');
|
|
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();
|
|
} 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"};
|
|
|
|
let categories = $state({...AUTO_CATEGORIES});
|
|
|
|
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
|
|
);
|
|
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 {
|
|
if (activeTab === 'notes') {
|
|
// Notes tab: show only pending videos that have notes
|
|
const res = await fetch(`/api/videos?status=pending&limit=50`);
|
|
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;
|
|
}
|
|
} else {
|
|
const params = new URLSearchParams({status: activeTab, limit: '200'});
|
|
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];
|
|
} else {
|
|
selectedVideo = null;
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Error loading videos:", err);
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function syncSubscriptions() {
|
|
isSyncing = true;
|
|
syncMessage = 'Syncing...';
|
|
try {
|
|
const res = await fetch('/api/videos/fetch', { method: 'POST', headers: getAuthHeaders() });
|
|
if (res.ok) {
|
|
syncMessage = 'Sync started...';
|
|
setTimeout(() => { loadVideos(); isSyncing = false; syncMessage = ''; }, 6000);
|
|
} else {
|
|
isSyncing = false;
|
|
syncMessage = 'Sync failed';
|
|
}
|
|
} 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) {
|
|
videos = videos.filter(v => v.video_id !== videoId);
|
|
if (selectedVideo?.video_id === videoId) selectedVideo = videos[0] || null;
|
|
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');
|
|
if (res.ok) {
|
|
const all = await res.json();
|
|
const map = {};
|
|
for (const item of all) map[item.video_id] = item.note_text;
|
|
notes = map;
|
|
}
|
|
} catch(e) { console.error('Failed to load notes:', e); }
|
|
}
|
|
|
|
async function migrateLocalNotes() {
|
|
try {
|
|
const local = JSON.parse(localStorage.getItem('t-yt-notes') || '{}');
|
|
const keys = Object.keys(local).filter(k => local[k].trim());
|
|
if (keys.length === 0) return;
|
|
const missing = keys.filter(k => !notes[k]);
|
|
if (missing.length === 0) return;
|
|
const payload = {};
|
|
for (const k of missing) payload[k] = local[k];
|
|
await fetch('/api/notes/migrate', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ notes: payload })
|
|
});
|
|
await loadNotes();
|
|
} catch(e) {}
|
|
}
|
|
|
|
async function saveNote() {
|
|
const vid = selectedVideo?.video_id;
|
|
if (!vid) return;
|
|
const textarea = document.querySelector('#note-textarea');
|
|
const current = textarea ? textarea.value : (notes[vid] || '');
|
|
if (!current.trim()) return;
|
|
// Save to server
|
|
await fetch(`/api/notes/${vid}`, {
|
|
method: 'POST',
|
|
headers: getAuthHeaders({ 'Content-Type': 'application/json' } ),
|
|
body: JSON.stringify({ note_text: current, queue_to_agent: queueToAgent })
|
|
});
|
|
// Update local state
|
|
notes = {...notes, [vid]: current};
|
|
activeNote = null;
|
|
queueToAgent = false;
|
|
}
|
|
|
|
// ---- Agent queue ----
|
|
|
|
async function loadAgentQueue() {
|
|
agentLoading = true;
|
|
try {
|
|
const res = await fetch('/api/agent-queue');
|
|
if (res.ok) agentItems = await res.json();
|
|
} 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 {
|
|
const res = await fetch('/api/config');
|
|
if (res.ok) {
|
|
const config = await res.json();
|
|
if (config.auth_enabled && !apiToken) {
|
|
showTokenSetup = true;
|
|
return false;
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
showTokenSetup = false;
|
|
return true;
|
|
}
|
|
|
|
async function setupToken() {
|
|
if (apiTokenInput.trim()) {
|
|
apiToken = apiTokenInput.trim();
|
|
localStorage.setItem('t-yt-token', apiToken);
|
|
showTokenSetup = false;
|
|
}
|
|
}
|
|
|
|
function getAuthHeaders(extraHeaders = {}) {
|
|
const headers = { ...extraHeaders };
|
|
if (apiToken) {
|
|
headers['Authorization'] = 'Bearer ' + apiToken;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
// ---- Lifecycle ----
|
|
|
|
$effect(() => { if (activeTab) loadVideos(); });
|
|
$effect(() => {
|
|
// Scroll to top when switching videos
|
|
if (selectedVideo && summaryPanel) {
|
|
tick().then(() => summaryPanel.scrollTop = 0);
|
|
}
|
|
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).
|
|
});
|
|
|
|
// Scroll-based auto-mark-read: mark video as read when user scrolls to bottom of summary
|
|
let scrollReadTimer = $state(null);
|
|
function handleSummaryScroll() {
|
|
if (!summaryPanel || !selectedVideo || activeTab !== 'pending') return;
|
|
const threshold = 100; // px from bottom
|
|
const atBottom = (summaryPanel.scrollHeight - summaryPanel.scrollTop - summaryPanel.clientHeight) < threshold;
|
|
if (atBottom) {
|
|
// Debounce to avoid rapid-fire updates
|
|
if (scrollReadTimer) clearTimeout(scrollReadTimer);
|
|
scrollReadTimer = setTimeout(() => {
|
|
const sv = selectedVideo;
|
|
if (sv && sv.summary && !sv.summary.includes('Error') && !sv.summary.includes('📺 Short clip')) {
|
|
updateStatus(sv.video_id, 'read', true);
|
|
}
|
|
}, 500);
|
|
}
|
|
}
|
|
$effect(() => {
|
|
if (activeTab === 'agent') loadAgentQueue();
|
|
});
|
|
|
|
onMount(async () => {
|
|
document.documentElement.className = theme;
|
|
await initAuth();
|
|
await loadNotes();
|
|
// Migrate localStorage notes to server (non-blocking)
|
|
migrateLocalNotes();
|
|
await loadVideos();
|
|
loaded = true;
|
|
loadPendingCount();
|
|
updateSyncTimer();
|
|
loadChannels();
|
|
window.addEventListener('keydown', handleKeydown);
|
|
return () => window.removeEventListener('keydown', handleKeydown);
|
|
});
|
|
|
|
function updateSyncTimer() {
|
|
const el = document.getElementById('sync-timer');
|
|
if (!el) return;
|
|
const now = new Date();
|
|
const next = new Date(now);
|
|
next.setHours(3, 0, 0, 0);
|
|
if (now > next) next.setDate(next.getDate() + 1);
|
|
const diff = next - now;
|
|
const h = Math.floor(diff / 3600000);
|
|
const m = Math.floor((diff % 3600000) / 60000);
|
|
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');
|
|
if (res.ok) {
|
|
const all = await res.json();
|
|
pendingCount = all.length >= 201 ? '200+' : all.length;
|
|
}
|
|
} 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;
|
|
// If no video selected, let keyboard scroll the page naturally
|
|
if (!selectedVideo) return;
|
|
const idx = filteredVideos.findIndex(v => v.video_id === selectedVideo.video_id);
|
|
let nextIdx = idx;
|
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (idx < filteredVideos.length - 1) nextIdx = idx + 1; else return;
|
|
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
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) {
|
|
saveNote();
|
|
} else if (selectedVideo) {
|
|
updateStatus(selectedVideo.video_id, 'read');
|
|
}
|
|
return;
|
|
} else if (e.key === '?') {
|
|
e.preventDefault();
|
|
showHelp = !showHelp;
|
|
return;
|
|
} else {
|
|
return;
|
|
}
|
|
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>');
|
|
html = html.replace(/^\s*[-*]\s+(.*)$/gm, '<li class="ml-5 list-disc mb-1" style="color:var(--text-primary)">$1</li>');
|
|
html = html.split('\n').map(line => {
|
|
if (line.trim().startsWith('<li')) return line;
|
|
if (line.trim() === '') return '<div class="h-2"></div>';
|
|
return `<p class="mb-2 leading-relaxed">${line}</p>`;
|
|
}).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">
|
|
<!-- Token Setup Modal -->
|
|
{#if showTokenSetup}
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center" style="background:rgba(0,0,0,0.5)">
|
|
<div class="bg-[var(--bg-secondary)] p-6 rounded-lg max-w-md w-full mx-4"
|
|
style="border:1px solid var(--border)">
|
|
<h2 class="text-xl font-bold mb-2">API Token Required</h2>
|
|
<p class="text-sm mb-4" style="color:var(--text-secondary)">
|
|
This app requires an API token to save notes and manage videos.
|
|
Please enter the token configured on the server.
|
|
</p>
|
|
<input type="text" bind:value={apiTokenInput}
|
|
placeholder="Enter API token..." class="w-full px-3 py-2 rounded"
|
|
style="background:var(--bg-primary);border:1px solid var(--border);color:var(--text-primary)"
|
|
onkeydown={(e) => e.key === 'Enter' && setupToken()} />
|
|
<div class="flex space-x-2 mt-4">
|
|
<button onclick={setupToken}
|
|
class="px-4 py-2 rounded text-white font-bold"
|
|
style="background:var(--accent)">
|
|
Submit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<header style="background:var(--bg-secondary);border-bottom:1px solid var(--border);" class="flex items-center justify-between px-5 py-2.5">
|
|
<div class="flex items-center space-x-3">
|
|
<span class="text-lg font-black tracking-wider" style="color:var(--accent)">T YouTube</span>
|
|
<span class="text-[10px] px-2 py-0.5 rounded font-semibold uppercase tracking-widest" style="background:var(--bg-tertiary);color:var(--text-muted)">text feed</span>
|
|
</div>
|
|
<div class="flex items-center space-x-3">
|
|
<button onclick={syncSubscriptions} disabled={isSyncing}
|
|
class="px-3 py-1.5 text-xs font-bold rounded-lg transition"
|
|
style="background:var(--accent);color:white">
|
|
{isSyncing ? 'Syncing...' : 'Sync'}
|
|
</button>
|
|
<button onclick={toggleTheme}
|
|
class="text-sm px-2 py-1 rounded transition" style="color:var(--text-muted)"
|
|
title="Toggle theme">
|
|
{theme === 'dark' ? '☀' : '☾'}
|
|
</button>
|
|
{#if !isSyncing}
|
|
<span class="text-[10px]" style="color:var(--text-muted)" id="sync-timer"></span>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<div class="flex flex-1 overflow-hidden">
|
|
<!-- Left sidebar -->
|
|
<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','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 === '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)">
|
|
{#if agentLoading}
|
|
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">Loading...</div>
|
|
{:else if agentItems.length === 0}
|
|
<div class="flex items-center justify-center py-8 text-xs" style="color:var(--text-muted)">
|
|
<p>No items queued.<br>Write a note on a video and check "Send to Agent" to add one.</p>
|
|
</div>
|
|
{:else}
|
|
{#each agentItems as item}
|
|
<div class="p-3 border-b" style="border-color:var(--border)">
|
|
<div class="flex items-start space-x-2">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="text-[11px] font-bold leading-tight line-clamp-2" style="color:var(--text-primary)">{item.title || 'Unknown'}</p>
|
|
<p class="text-[10px] mt-1" style="color:var(--accent)">{item.channel_name || ''}</p>
|
|
<p class="text-[10px] mt-1" style="color:var(--text-muted)">Prompt: {item.user_prompt.slice(0, 120)}{item.user_prompt.length > 120 ? '...' : ''}</p>
|
|
<div class="flex items-center space-x-2 mt-1.5">
|
|
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
|
|
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
|
|
{item.status}
|
|
</span>
|
|
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
|
|
</div>
|
|
{#if item.status === 'pending'}
|
|
<button onclick={() => removeAgentItem(item.id)}
|
|
class="text-[9px] mt-1 px-2 py-0.5 rounded border"
|
|
style="color:var(--text-muted);border-color:var(--border)">
|
|
Remove
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/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)">
|
|
<select bind:value={channelFilter}
|
|
class="w-full text-[11px] p-1.5 rounded border bg-transparent"
|
|
style="color:var(--text-primary);border-color:var(--border);background:var(--bg-tertiary)">
|
|
<option value="">All Channels</option>
|
|
{#if catList.length}
|
|
<option disabled>── Categories ──</option>
|
|
{#each catList as cat}
|
|
<option value={'📁 ' + cat}>📁 {cat}</option>
|
|
{/each}
|
|
<option disabled>── Channels ──</option>
|
|
{/if}
|
|
{#each allChannels.length ? allChannels.map(c => c.channel_name) : channels as ch}
|
|
<option value={ch}>{ch}</option>
|
|
{/each}
|
|
</select>
|
|
<button onclick={() => editingCategories = !editingCategories}
|
|
class="w-full text-[10px] py-1 rounded border transition text-center"
|
|
style="color:var(--text-muted);border-color:var(--border)">
|
|
{editingCategories ? 'Done' : '✎ Edit Categories'}
|
|
</button>
|
|
<button onclick={async () => {
|
|
const res = await fetch('/auto_categories.json');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
for (const [k, v] of Object.entries(data)) categories[k] = v;
|
|
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
|
|
loadChannels();
|
|
}
|
|
}}
|
|
class="w-full text-[10px] py-1 rounded border transition text-center"
|
|
style="color:var(--text-muted);border-color:var(--border)">
|
|
↻ Reset to Auto
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{#if editingCategories}
|
|
<div class="overflow-y-auto max-h-48 p-2 space-y-1" style="border-bottom:1px solid var(--border);background:var(--bg-tertiary)">
|
|
<div class="text-[10px] pb-1" style="color:var(--text-muted)">Type category name next to each channel:</div>
|
|
<input class="w-full text-[11px] p-1 rounded border mb-1" style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
|
|
placeholder="New category name..." id="new-cat-input"
|
|
onkeydown={e => { if (e.key === 'Enter') { const v = e.target.value.trim(); if (v) { categories[v] = []; localStorage.setItem('t-yt-categories', JSON.stringify(categories)); e.target.value = ''; } } }}>
|
|
{#each allChannels as ch}
|
|
<div class="flex items-center space-x-2 text-[11px]">
|
|
<span class="flex-1 truncate" style="color:var(--text-primary)">{ch.channel_name}</span>
|
|
<input class="w-20 text-[10px] p-0.5 rounded border text-center"
|
|
style="background:var(--bg-primary);color:var(--text-primary);border-color:var(--border)"
|
|
placeholder="cat"
|
|
value={categories[ch.channel_name] || ''}
|
|
oninput={e => {
|
|
categories[ch.channel_name] = e.target.value;
|
|
localStorage.setItem('t-yt-categories', JSON.stringify(categories));
|
|
}}>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
<!-- Video list -->
|
|
<div 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; }}
|
|
data-video-index={i}
|
|
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'}">
|
|
<!-- 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-[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}
|
|
</div>
|
|
{/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'}
|
|
<div class="p-6 max-w-3xl mx-auto">
|
|
<h2 class="text-sm font-black tracking-wider mb-4" style="color:var(--accent)">🤖 Agent Queue</h2>
|
|
{#if agentItems.length === 0}
|
|
<p class="text-xs opacity-50 italic">Queue empty. Watch a video, press <kbd class="px-1 py-0.5 rounded text-[10px]" style="background:var(--bg-tertiary)">n</kbd> to write a note, check "Send to Agent", and save.</p>
|
|
{:else}
|
|
<div class="space-y-3">
|
|
{#each agentItems as item}
|
|
<div class="p-4 rounded-xl border" style="background:var(--card-bg);border-color:var(--border)">
|
|
<div class="flex items-start space-x-3">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="text-sm font-bold" style="color:var(--text-primary)">{item.title || 'Unknown'}</h3>
|
|
<p class="text-[10px] mt-0.5" style="color:var(--accent)">{item.channel_name}</p>
|
|
<div class="mt-3 p-2 rounded text-xs leading-relaxed" style="background:var(--bg-tertiary)">
|
|
<span class="font-bold text-[10px]" style="color:var(--text-muted)">PROMPT:</span>
|
|
<p class="mt-1 whitespace-pre-wrap" style="color:var(--text-primary)">{item.user_prompt}</p>
|
|
</div>
|
|
<div class="flex items-center space-x-2 mt-2">
|
|
<span class="text-[9px] px-1.5 py-0.5 rounded font-bold uppercase"
|
|
style="background:{item.status === 'done' ? '#10b981' : item.status === 'processing' ? '#f59e0b' : item.status === 'failed' ? '#ef4444' : 'var(--bg-tertiary)'};color:white">
|
|
{item.status}
|
|
</span>
|
|
<span class="text-[9px]" style="color:var(--text-muted)">{formatDate(item.created_at)}</span>
|
|
<a href={item.url} target="_blank" rel="noopener noreferrer"
|
|
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--accent);border-color:var(--accent)">▶ Watch</a>
|
|
{#if item.status === 'pending'}
|
|
<button onclick={() => removeAgentItem(item.id)}
|
|
class="text-[9px] px-2 py-0.5 rounded border" style="color:var(--text-muted);border-color:var(--border)">Remove</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if selectedVideo}
|
|
<div class="p-6 max-w-3xl mx-auto space-y-4">
|
|
<!-- 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 cursor-pointer hover:opacity-80 transition"
|
|
style="color:var(--accent);background:var(--bg-tertiary)"
|
|
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 || ''}
|
|
{#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>
|
|
<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 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...
|
|
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
|
|
onkeydown={e => { if (e.key === 'Escape') { saveNote(); } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(); } }}
|
|
></textarea>
|
|
<div class="flex items-center space-x-3">
|
|
<label class="flex items-center space-x-1.5 text-[11px] cursor-pointer" style="color:var(--text-muted)">
|
|
<input type="checkbox" bind:checked={queueToAgent}
|
|
class="w-3 h-3 rounded" style="accent-color:var(--accent)">
|
|
<span>Send to Agent</span>
|
|
</label>
|
|
<div class="flex-1"></div>
|
|
<button onclick={saveNote}
|
|
class="text-[10px] px-3 py-1 text-white font-bold rounded transition" style="background:var(--accent)">
|
|
{queueToAgent ? 'Save + 🤖 Queue' : '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}
|
|
|
|
<!-- 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>
|
|
{:else}
|
|
<div class="flex-1 flex items-center justify-center" style="color:var(--text-muted)">
|
|
<p>Select a video to read its summary</p>
|
|
</div>
|
|
{/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>
|