Initial commit: Add T YouTube text-based reader web app

- FastAPI backend running on port 8000
- Svelte 5 runes frontend dashboard supporting Read/Skip state toggles
- YouTube subscription feed scraper using yt-dlp & Netscape cookies.txt
- Transcript downloader and Claude 3.5 Haiku summarizer via bytecatcode proxy
- Multi-stage Dockerfile and docker-compose.yml configuration

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-06-03 11:00:34 +08:00
commit dbd3dc0237
17 changed files with 967 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# Stage 1: Build Svelte frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Python runtime backend
FROM python:3.10-slim AS backend-runner
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Download latest official yt-dlp to ensure subscription scrapers do not break
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod a+rx /usr/local/bin/yt-dlp
# Install python packages
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy built static frontend
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
# Copy Python backend
COPY backend/ ./backend
# Directory setups for mounts
ENV DB_DIR=/app/data
ENV COOKIES_PATH=/app/config/cookies.txt
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["python", "backend/main.py"]
+66
View File
@@ -0,0 +1,66 @@
# T YouTube - Text-Based YouTube Digest
A text-based reader for YouTube. It syncs with your logged-in YouTube subscription feed, fetches video transcripts, generates concise summaries via Claude 3.5 Haiku, and displays them in a Svelte 5 + FastAPI web dashboard.
---
## 🚀 Setup Instructions
### 1. Extract your YouTube browser cookies
Since YouTube restricts subscription feeds to logged-in users, you need to export your logged-in cookies so `yt-dlp` can request the feed on your behalf:
1. Install a browser extension like **Get cookies.txt LOCALLY** (Chrome/Edge) or **cookies.txt** (Firefox).
2. Open YouTube in your browser and ensure you are logged in to your account.
3. Click the extension icon and export the cookies for `youtube.com` as a Netscape-formatted text file.
4. Create a folder named `config` in your project root, rename the exported file to `cookies.txt`, and save it inside:
```text
t-youtube/config/cookies.txt
```
### 2. Configure Environment Variables
Create a `.env` file in the root directory:
```env
# Optional: Override the fallback OpenAI / Anthropic key
OPENAI_API_KEY=sk-Kq5JbgmnEEkdzTqNAQ6bV6nztQ0ngeHTHF9Vg8nUglQvrOlx
```
### 3. Build & Deploy using Docker Compose
Run the following command to build the multi-stage image (compiles frontend static assets using Node, then packages backend with Python) and start the container:
```bash
docker compose up -d --build
```
The server will boot up and listen on port `8000`.
---
## 📁 Repository Structure
```text
t-youtube/
├── backend/
│ ├── main.py # FastAPI server & endpoints
│ ├── db.py # aiosqlite database schema initialization
│ ├── fetcher.py # Subscription scraper using yt-dlp
│ ├── summarizer.py # Transcript retriever & Claude API client
│ └── requirements.txt # Python dependencies
├── frontend/
│ ├── src/
│ │ ├── routes/
│ │ │ └── YoutubeDashboard.svelte # Svelte 5 runes UI
│ │ ├── App.svelte # Svelte App entry point
│ │ └── main.js
│ ├── package.json # NPM package definitions
│ └── vite.config.js # Vite dev server proxy rules
├── Dockerfile # Multi-stage production build script
└── docker-compose.yml # Synology NAS deployment config
```
---
## 📋 Features & Usage
1. **Sync Subscriptions**: Click the **Sync Subscriptions** button at the top of the dashboard. This triggers a background process that fetches your subscription feed, compares it with the database, adds new entries, extracts transcripts, and generates Claude digests automatically.
2. **Fast Reading Layout**: Click on any video card in the sidebar. You will instantly see the channel name, publish date, video length, and the AI-generated digest containing:
* **Core Hook (Bolded)**
* **Key Takeaways (Bullet list)**
* **Actionable Verdict**
3. **Read/Skip State**: Mark videos as **Read** or **Skip** to clear them from your pending queue, allowing you to only keep track of what's unread.
4. **View Full Transcript**: Expand the accordion at the bottom of the digest to read the raw transcripts if needed.
+34
View File
@@ -0,0 +1,34 @@
import os
import aiosqlite
DB_DIR = os.environ.get("DB_DIR", os.path.dirname(os.path.abspath(__file__)))
DB_PATH = os.path.join(DB_DIR, "t_youtube.db")
async def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS videos (
video_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
channel_name TEXT,
channel_id TEXT,
url TEXT NOT NULL,
thumbnail_url TEXT,
publish_date TEXT,
duration INTEGER,
transcript TEXT,
summary TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
async def get_db():
db = await aiosqlite.connect(DB_PATH)
db.row_factory = aiosqlite.Row
try:
yield db
finally:
await db.close()
+96
View File
@@ -0,0 +1,96 @@
import asyncio
import json
import os
import logging
import aiosqlite
from db import DB_PATH, init_db
log = logging.getLogger(__name__)
COOKIES_PATH = os.environ.get("COOKIES_PATH", "/app/config/cookies.txt")
if not os.path.exists(COOKIES_PATH):
# Fallback to local directory
COOKIES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies.txt")
async def fetch_subscriptions():
if not os.path.exists(COOKIES_PATH):
log.error(f"cookies.txt not found at {COOKIES_PATH}. Ingestion aborted.")
return 0
log.info(f"Fetching subscriptions using cookies at {COOKIES_PATH}")
cmd = [
"yt-dlp",
"--flat-playlist",
"--dump-json",
"--cookies", COOKIES_PATH,
"--playlist-end", "40",
"https://www.youtube.com/feed/subscriptions"
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
except Exception as e:
log.error(f"Failed to run yt-dlp: {e}")
return 0
if process.returncode != 0:
log.error(f"yt-dlp error output: {stderr.decode('utf-8', errors='ignore')}")
return 0
lines = stdout.decode('utf-8', errors='ignore').strip().split('\n')
new_count = 0
async with aiosqlite.connect(DB_PATH) as db:
for line in lines:
if not line:
continue
try:
video = json.loads(line)
video_id = video.get("id")
if not video_id:
continue
title = video.get("title", "Untitled")
channel_name = video.get("uploader") or video.get("channel") or "Unknown Channel"
channel_id = video.get("uploader_id") or video.get("channel_id")
url = f"https://www.youtube.com/watch?v={video_id}"
thumbnail_url = video.get("thumbnail") or f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
raw_date = video.get("upload_date")
if raw_date and len(raw_date) == 8:
publish_date = f"{raw_date[:4]}-{raw_date[4:6]}-{raw_date[6:]}"
else:
publish_date = None
duration = video.get("duration")
# Check if video already exists in db
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone()
if not exists:
await db.execute("""
INSERT INTO videos (video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date, duration, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
""", (video_id, title, channel_name, channel_id, url, thumbnail_url, publish_date, duration))
new_count += 1
except Exception as e:
log.warning(f"Error parsing yt-dlp line: {e}")
continue
await db.commit()
log.info(f"Ingestion completed. Found {new_count} new videos.")
return new_count
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
async def main():
await init_db()
await fetch_subscriptions()
asyncio.run(main())
+103
View File
@@ -0,0 +1,103 @@
import os
import logging
from fastapi import FastAPI, BackgroundTasks, Depends, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import Optional, List
import aiosqlite
from db import init_db, get_db
from fetcher import fetch_subscriptions
from summarizer import summarize_video, summarize_all_pending
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
log = logging.getLogger(__name__)
app = FastAPI(title="T YouTube", description="Text-based YouTube Summarizer")
# Enable CORS for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class StatusUpdate(BaseModel):
status: str
# Database Startup Hook
@app.on_event("startup")
async def startup():
await init_db()
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/videos")
async def get_videos(
status: Optional[str] = "pending",
limit: int = 50,
db: aiosqlite.Connection = Depends(get_db)
):
query = "SELECT * FROM videos WHERE status = ? ORDER BY publish_date DESC, created_at DESC LIMIT ?"
cursor = await db.execute(query, (status, limit))
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def bg_fetch_and_summarize():
log.info("Starting background subscription fetch and summarization loop...")
try:
new_count = await fetch_subscriptions()
if new_count > 0:
await summarize_all_pending()
except Exception as e:
log.error(f"Error in background fetch and summarize task: {e}")
@app.post("/api/videos/fetch")
async def trigger_fetch(background_tasks: BackgroundTasks):
background_tasks.add_task(bg_fetch_and_summarize)
return {"message": "Subscription sync and summarization running in background."}
@app.post("/api/videos/{video_id}/summarize")
async def trigger_summarize(video_id: str, background_tasks: BackgroundTasks, db: aiosqlite.Connection = Depends(get_db)):
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
background_tasks.add_task(summarize_video, video_id)
return {"message": f"Summarization for video {video_id} queued."}
@app.post("/api/videos/{video_id}/status")
async def update_status(
video_id: str,
update: StatusUpdate,
db: aiosqlite.Connection = Depends(get_db)
):
if update.status not in ["pending", "read", "skipped"]:
raise HTTPException(status_code=400, detail="Invalid status value")
cursor = await db.execute("SELECT 1 FROM videos WHERE video_id = ?", (video_id,))
exists = await cursor.fetchone()
if not exists:
raise HTTPException(status_code=404, detail="Video not found")
await db.execute("UPDATE videos SET status = ? WHERE video_id = ?", (update.status, video_id))
await db.commit()
return {"message": f"Video {video_id} status updated to {update.status}."}
# Mount frontend built files for production if they exist
frontend_build_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend/dist")
if os.path.exists(frontend_build_path):
app.mount("/", StaticFiles(directory=frontend_build_path, html=True), name="frontend")
log.info("Mounted static frontend build successfully.")
else:
log.warning("Frontend build directory 'frontend/dist' not found. API only mode is active.")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.100.0
uvicorn>=0.20.0
yt-dlp>=2023.0.0
youtube-transcript-api>=0.6.0
aiosqlite>=0.18.0
httpx>=0.24.0
+140
View File
@@ -0,0 +1,140 @@
import os
import asyncio
import logging
import httpx
import aiosqlite
from youtube_transcript_api import YouTubeTranscriptApi
from db import DB_PATH
log = logging.getLogger(__name__)
# Fallback pattern to retrieve the correct bytecatcode credentials
API_KEY = os.environ.get("ANTHROPIC_API_KEY")
if not API_KEY:
API_KEY = os.environ.get("OPENAI_API_KEY", "sk-Kq5JbgmnEEkdzTqNAQ6bV6nztQ0ngeHTHF9Vg8nUglQvrOlx")
BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://www.bytecatcode.org")
MODEL = "claude-haiku-4-5-20251001"
SYSTEM_PROMPT = "You are a professional video summarizing assistant. Create highly concise, structured summaries from video transcripts. Your output must be in Chinese if the transcript is Chinese, otherwise in English."
PROMPT = """Read the following YouTube video transcript and write a super-concise, structured summary.
Guidelines:
1. **Core Hook (1 sentence in bold)**: Explain exactly what this video is about and its main conclusion or thesis.
2. **Key Takeaways (max 4-5 bullet points)**: Summarize the most important points, arguments, or steps. Focus on information density. Skip fluff, intros, sponsor segments, and generic remarks.
3. **Actionable Idea / Final Verdict**: A single line explaining who this is for or a key action item.
Keep the entire summary under 150 words. Be extremely direct so I can decide in 10 seconds if I should watch the full video or skip it. Use the same language as the transcript (likely Chinese or English).
Transcript:
{transcript}"""
def download_transcript(video_id: str) -> str:
try:
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
# Try to find manual transcripts in preferred languages
try:
transcript = transcript_list.find_manually_created_transcript(['en', 'zh-Hans', 'zh-Hant', 'zh', 'ja'])
except Exception:
# Fallback to generated (auto-captions) in preferred languages
try:
transcript = transcript_list.find_generated_transcript(['en', 'zh-Hans', 'zh-Hant', 'zh', 'ja'])
except Exception:
# Fallback to any transcript
transcript = next(iter(transcript_list))
parts = transcript.fetch()
text = " ".join([part["text"] for part in parts])
return text
except Exception as e:
log.warning(f"Failed to fetch transcript for {video_id}: {e}")
return None
async def summarize_video(video_id: str) -> str:
# 1. Fetch transcript
transcript = download_transcript(video_id)
if not transcript:
# Save placeholder transcript / status
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE videos SET transcript = ?, summary = ? WHERE video_id = ?",
("No transcript available", "Could not retrieve video transcript.", video_id)
)
await db.commit()
return None
# 2. Call Claude API
headers = {
"x-api-key": API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
# Standard Anthropic payload format
payload = {
"model": MODEL,
"max_tokens": 1000,
"system": SYSTEM_PROMPT,
"messages": [
{"role": "user", "content": PROMPT.format(transcript=transcript[:50000])}
]
}
async with httpx.AsyncClient(timeout=120) as client:
try:
resp = await client.post(
f"{BASE_URL}/v1/messages",
headers=headers,
json=payload
)
resp.raise_for_status()
data = resp.json()
summary = data["content"][0]["text"]
except Exception as e:
log.error(f"Claude API request failed for {video_id}: {e}")
summary = f"Error generating summary: {e}"
# 3. Save transcript & summary to db
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"UPDATE videos SET transcript = ?, summary = ? WHERE video_id = ?",
(transcript, summary, video_id)
)
await db.commit()
return summary
async def summarize_all_pending():
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT video_id FROM videos WHERE summary IS NULL OR summary = ''")
rows = await cursor.fetchall()
if not rows:
log.info("No pending videos to summarize.")
return
log.info(f"Found {len(rows)} pending videos to summarize.")
for row in rows:
video_id = row["video_id"]
log.info(f"Summarizing video: {video_id}")
await summarize_video(video_id)
await asyncio.sleep(1)
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
async def main():
if len(sys.argv) > 1:
video_id = sys.argv[1]
print(f"Summarizing single video: {video_id}")
summary = await summarize_video(video_id)
print(f"Summary:\n{summary}")
else:
await summarize_all_pending()
asyncio.run(main())
+18
View File
@@ -0,0 +1,18 @@
version: '3.8'
services:
t-youtube:
build:
context: .
dockerfile: Dockerfile
container_name: t-youtube
restart: unless-stopped
ports:
- "8000:8000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-Kq5JbgmnEEkdzTqNAQ6bV6nztQ0ngeHTHF9Vg8nUglQvrOlx}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- ANTHROPIC_BASE_URL=https://www.bytecatcode.org
volumes:
- ./data:/app/data
- ./config:/app/config
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>T YouTube - Text-Based YouTube Digest</title>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "t-youtube-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"vite": "^5.2.11"
},
"dependencies": {
"svelte": "^5.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+7
View File
@@ -0,0 +1,7 @@
<script>
import YoutubeDashboard from './routes/YoutubeDashboard.svelte';
</script>
<main class="min-h-screen bg-gray-950 text-gray-100 font-sans">
<YoutubeDashboard />
</main>
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+9
View File
@@ -0,0 +1,9 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;
+380
View File
@@ -0,0 +1,380 @@
<script>
import { onMount } from 'svelte';
// Svelte 5 Runes for Reactive State
let videos = $state([]);
let selectedVideo = $state(null);
let activeTab = $state('pending'); // pending, read, skipped
let isLoading = $state(false);
let isSyncing = $state(false);
let syncMessage = $state('');
// Fetch videos from backend api
async function loadVideos() {
isLoading = true;
try {
const res = await fetch(`/api/videos?status=${activeTab}&limit=50`);
if (res.ok) {
videos = await res.json();
if (videos.length > 0) {
// Keep current video selected if still in the list, otherwise select first
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;
}
}
// Trigger subscription sync in background
async function syncSubscriptions() {
isSyncing = true;
syncMessage = 'Syncing subscriptions & generating summaries...';
try {
const res = await fetch('/api/videos/fetch', { method: 'POST' });
if (res.ok) {
syncMessage = 'Sync started in background. Refreshing in 6 seconds...';
setTimeout(() => {
loadVideos();
isSyncing = false;
syncMessage = '';
}, 6000);
}
} catch (err) {
console.error("Error syncing:", err);
isSyncing = false;
syncMessage = '';
}
}
// Update status (pending -> read/skipped etc)
async function updateStatus(videoId, newStatus) {
try {
const res = await fetch(`/api/videos/${videoId}/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
// Remove from current list
videos = videos.filter(v => v.video_id !== videoId);
if (selectedVideo && selectedVideo.video_id === videoId) {
selectedVideo = videos.length > 0 ? videos[0] : null;
}
}
} catch (err) {
console.error("Error updating status:", err);
}
}
// Trigger summary generation for a single video manually
async function generateSummary(videoId) {
if (selectedVideo && selectedVideo.video_id === videoId) {
selectedVideo.summary = "Generating summary now, please wait...";
}
try {
const res = await fetch(`/api/videos/${videoId}/summarize`, { method: 'POST' });
if (res.ok) {
setTimeout(() => {
loadVideos();
}, 3000);
}
} catch (err) {
console.error("Error summarizing:", err);
}
}
// Watch for tab change
$effect(() => {
if (activeTab) {
loadVideos();
}
});
onMount(() => {
loadVideos();
});
function formatDuration(seconds) {
if (!seconds) return '';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
function formatMarkdown(text) {
if (!text) return '<p class="text-gray-400">Loading summary...</p>';
// Parse bold text **example**
let html = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-bold text-red-400">$1</strong>');
// Parse bullet points
html = html.replace(/^\s*[-*]\s+(.*)$/gm, '<li class="ml-5 list-disc mb-1 text-gray-200">$1</li>');
// Handle line breaks
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;
}
</script>
<div class="flex flex-col h-screen bg-gray-950 text-gray-100">
<!-- Top Navigation Header -->
<header class="flex items-center justify-between px-6 py-4 bg-gray-900 border-b border-gray-800">
<div class="flex items-center space-x-3">
<span class="text-2xl font-black tracking-wider text-red-500">T YouTube</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400 uppercase tracking-widest font-semibold border border-gray-700">text feed</span>
</div>
<div class="flex items-center space-x-4">
{#if syncMessage}
<span class="text-xs text-red-400 animate-pulse">{syncMessage}</span>
{/if}
<button
onclick={syncSubscriptions}
disabled={isSyncing}
class="flex items-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-800 disabled:text-gray-600 text-white text-sm font-semibold rounded-lg shadow-lg hover:shadow-red-900/30 transition duration-150"
>
{#if isSyncing}
<svg class="animate-spin h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Syncing...</span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.213 6H16" />
</svg>
<span>Sync Subscriptions</span>
{/if}
</button>
</div>
</header>
<!-- Main Grid Dashboard layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Left Navigation Sidebar & Video List -->
<aside class="w-80 md:w-96 flex flex-col border-r border-gray-900 bg-gray-900/40">
<!-- Tabs Selector -->
<div class="flex p-2 bg-gray-900 border-b border-gray-900">
<button
onclick={() => activeTab = 'pending'}
class="flex-1 py-2 text-center text-xs font-bold rounded-md tracking-wider uppercase transition duration-150 {activeTab === 'pending' ? 'bg-red-600/10 text-red-400 border border-red-500/20' : 'text-gray-400 hover:text-gray-200'}"
>
Pending
</button>
<button
onclick={() => activeTab = 'read'}
class="flex-1 py-2 text-center text-xs font-bold rounded-md tracking-wider uppercase transition duration-150 {activeTab === 'read' ? 'bg-green-600/10 text-green-400 border border-green-500/20' : 'text-gray-400 hover:text-gray-200'}"
>
Read
</button>
<button
onclick={() => activeTab = 'skipped'}
class="flex-1 py-2 text-center text-xs font-bold rounded-md tracking-wider uppercase transition duration-150 {activeTab === 'skipped' ? 'bg-gray-800 text-gray-300 border border-gray-700/50' : 'text-gray-400 hover:text-gray-200'}"
>
Skipped
</button>
</div>
<!-- Scrollable Video Cards List -->
<div class="flex-1 overflow-y-auto divide-y divide-gray-900/50">
{#if isLoading && videos.length === 0}
<div class="flex flex-col items-center justify-center h-48 space-y-2">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-red-500"></div>
<span class="text-sm text-gray-500">Loading videos...</span>
</div>
{:else if videos.length === 0}
<div class="flex flex-col items-center justify-center h-64 text-gray-500 space-y-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
<span class="text-sm">No videos found.</span>
</div>
{:else}
{#each videos as video (video.video_id)}
<button
onclick={() => selectedVideo = video}
class="w-full text-left p-3.5 hover:bg-gray-900/60 focus:outline-none transition duration-150 flex items-start space-x-3.5 {selectedVideo?.video_id === video.video_id ? 'bg-gray-900 border-l-4 border-red-500 pl-2.5' : 'border-l-4 border-transparent'}"
>
<!-- Thumbnail/Duration badge -->
<div class="relative w-24 h-14 bg-gray-800 rounded overflow-hidden flex-shrink-0">
<img src={video.thumbnail_url} alt="" class="object-cover w-full h-full" />
{#if video.duration}
<span class="absolute bottom-0.5 right-0.5 bg-black/85 text-[10px] font-bold px-1 rounded text-white tracking-wider">
{formatDuration(video.duration)}
</span>
{/if}
</div>
<!-- Video metadata summary -->
<div class="flex-1 min-w-0">
<span class="block text-[11px] font-semibold text-red-500/80 truncate mb-0.5">{video.channel_name}</span>
<h3 class="text-sm font-bold text-gray-100 line-clamp-2 leading-snug">{video.title}</h3>
<span class="block text-[10px] text-gray-500 mt-1">{video.publish_date || 'Date unknown'}</span>
</div>
</button>
{/each}
{/if}
</div>
</aside>
<!-- Right Detailed Digest Panel -->
<main class="flex-1 flex flex-col bg-gray-950/80 overflow-y-auto">
{#if selectedVideo}
<div class="p-6 md:p-8 max-w-4xl w-full mx-auto space-y-6">
<!-- Video Details Header -->
<div class="border-b border-gray-900 pb-5 space-y-3">
<span class="text-xs px-2.5 py-1 font-bold bg-gray-900 text-red-400 rounded-full border border-gray-800">
{selectedVideo.channel_name}
</span>
<h1 class="text-2xl md:text-3xl font-extrabold text-gray-100 tracking-tight leading-tight">
{selectedVideo.title}
</h1>
<div class="flex items-center space-x-4 text-xs text-gray-500">
<span>Published: <strong>{selectedVideo.publish_date || 'Unknown'}</strong></span>
{#if selectedVideo.duration}
<span></span>
<span>Length: <strong>{formatDuration(selectedVideo.duration)}</strong></span>
{/if}
</div>
</div>
<!-- Video Actions buttons block -->
<div class="flex flex-wrap gap-3">
{#if activeTab === 'pending'}
<button
onclick={() => updateStatus(selectedVideo.video_id, 'read')}
class="px-5 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-bold rounded-lg shadow-lg shadow-green-950/30 transition duration-150"
>
Mark as Read
</button>
<button
onclick={() => updateStatus(selectedVideo.video_id, 'skipped')}
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-bold rounded-lg border border-gray-700 transition duration-150"
>
Skip Video
</button>
{:else if activeTab === 'read'}
<button
onclick={() => updateStatus(selectedVideo.video_id, 'pending')}
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-bold rounded-lg border border-gray-700 transition duration-150"
>
Revert to Pending
</button>
<button
onclick={() => updateStatus(selectedVideo.video_id, 'skipped')}
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-bold rounded-lg border border-gray-700 transition duration-150"
>
Move to Skipped
</button>
{:else if activeTab === 'skipped'}
<button
onclick={() => updateStatus(selectedVideo.video_id, 'pending')}
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-bold rounded-lg border border-gray-700 transition duration-150"
>
Revert to Pending
</button>
<button
onclick={() => updateStatus(selectedVideo.video_id, 'read')}
class="px-5 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-bold rounded-lg transition duration-150"
>
Move to Read
</button>
{/if}
<a
href={selectedVideo.url}
target="_blank"
rel="noopener noreferrer"
class="px-5 py-2.5 bg-red-600 hover:bg-red-700 text-white text-sm font-bold rounded-lg flex items-center space-x-2 transition duration-150"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
<span>Watch on YouTube</span>
</a>
</div>
<!-- Video Visual Block -->
<div class="relative w-full aspect-video rounded-xl overflow-hidden bg-gray-900 border border-gray-800 shadow-2xl">
<img src={selectedVideo.thumbnail_url} alt="" class="object-cover w-full h-full opacity-40 blur-sm" />
<div class="absolute inset-0 flex flex-col items-center justify-center p-4">
<div class="relative w-36 h-20 rounded shadow-md overflow-hidden mb-3 border border-gray-700">
<img src={selectedVideo.thumbnail_url} alt="" class="object-cover w-full h-full" />
</div>
<a
href={selectedVideo.url}
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2 bg-red-600/90 hover:bg-red-600 text-white text-xs font-extrabold uppercase tracking-wider rounded border border-red-500 shadow transition duration-150"
>
Play original video
</a>
</div>
</div>
<!-- Digest Content Block -->
<div class="bg-gray-900/60 rounded-xl border border-gray-800/80 p-6 md:p-8 space-y-6">
<div class="flex items-center justify-between border-b border-gray-800 pb-3">
<h2 class="text-lg font-black tracking-wide text-gray-200 uppercase">Video Digest Highlight</h2>
{#if !selectedVideo.summary || selectedVideo.summary.includes("Error") || selectedVideo.summary.includes("Could not")}
<button
onclick={() => generateSummary(selectedVideo.video_id)}
class="text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 text-gray-300 font-semibold rounded border border-gray-700 transition"
>
Generate Summary
</button>
{/if}
</div>
<div class="prose prose-invert max-w-none text-gray-300 text-base leading-relaxed">
{@html formatMarkdown(selectedVideo.summary)}
</div>
</div>
<!-- Full Transcript Accordion -->
{#if selectedVideo.transcript && selectedVideo.transcript !== "No transcript available"}
<details class="group bg-gray-900/20 border border-gray-900 rounded-lg overflow-hidden transition">
<summary class="flex justify-between items-center p-4 font-bold text-sm text-gray-400 hover:text-gray-200 hover:bg-gray-900/30 cursor-pointer select-none">
<span>View Full Video Transcript</span>
<span class="transition group-open:rotate-180">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</summary>
<div class="p-5 border-t border-gray-900 bg-gray-950/40 text-sm text-gray-400 leading-relaxed max-h-96 overflow-y-auto font-mono whitespace-pre-line">
{selectedVideo.transcript}
</div>
</details>
{/if}
</div>
{:else}
<div class="flex-1 flex flex-col items-center justify-center text-gray-500 p-8 space-y-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<h2 class="text-lg font-bold text-gray-400">No Video Selected</h2>
<p class="text-sm text-gray-600 max-w-xs text-center">Click a video from the sidebar list to read its digest highlights.</p>
</div>
{/if}
</main>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{svelte,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
});