From dbd3dc02375bbacfda000c79cd4fc0514607046b Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Wed, 3 Jun 2026 11:00:34 +0800 Subject: [PATCH] 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 --- Dockerfile | 40 +++ README.md | 66 ++++ backend/db.py | 34 ++ backend/fetcher.py | 96 +++++ backend/main.py | 103 ++++++ backend/requirements.txt | 6 + backend/summarizer.py | 140 ++++++++ docker-compose.yml | 18 + frontend/index.html | 12 + frontend/package.json | 21 ++ frontend/postcss.config.js | 6 + frontend/src/App.svelte | 7 + frontend/src/app.css | 3 + frontend/src/main.js | 9 + frontend/src/routes/YoutubeDashboard.svelte | 380 ++++++++++++++++++++ frontend/tailwind.config.js | 11 + frontend/vite.config.js | 15 + 17 files changed, 967 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/db.py create mode 100644 backend/fetcher.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/summarizer.py create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/app.css create mode 100644 frontend/src/main.js create mode 100644 frontend/src/routes/YoutubeDashboard.svelte create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f490c0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..76ed38d --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..a92b7df --- /dev/null +++ b/backend/db.py @@ -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() diff --git a/backend/fetcher.py b/backend/fetcher.py new file mode 100644 index 0000000..49f6116 --- /dev/null +++ b/backend/fetcher.py @@ -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()) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..8c15c70 --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..5da6060 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/summarizer.py b/backend/summarizer.py new file mode 100644 index 0000000..7374374 --- /dev/null +++ b/backend/summarizer.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8818f87 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..61ae31a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + T YouTube - Text-Based YouTube Digest + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..64ee726 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..5ec9622 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,7 @@ + + +
+ +
diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..a2beaa5 --- /dev/null +++ b/frontend/src/main.js @@ -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; diff --git a/frontend/src/routes/YoutubeDashboard.svelte b/frontend/src/routes/YoutubeDashboard.svelte new file mode 100644 index 0000000..3ce520b --- /dev/null +++ b/frontend/src/routes/YoutubeDashboard.svelte @@ -0,0 +1,380 @@ + + +
+ +
+
+ T YouTube + text feed +
+ +
+ {#if syncMessage} + {syncMessage} + {/if} + +
+
+ + +
+ + + + +
+ {#if selectedVideo} +
+ +
+ + {selectedVideo.channel_name} + +

+ {selectedVideo.title} +

+ +
+ Published: {selectedVideo.publish_date || 'Unknown'} + {#if selectedVideo.duration} + + Length: {formatDuration(selectedVideo.duration)} + {/if} +
+
+ + +
+ {#if activeTab === 'pending'} + + + {:else if activeTab === 'read'} + + + {:else if activeTab === 'skipped'} + + + {/if} + + + + + + Watch on YouTube + +
+ + + + + +
+
+

Video Digest Highlight

+ + {#if !selectedVideo.summary || selectedVideo.summary.includes("Error") || selectedVideo.summary.includes("Could not")} + + {/if} +
+ +
+ {@html formatMarkdown(selectedVideo.summary)} +
+
+ + + {#if selectedVideo.transcript && selectedVideo.transcript !== "No transcript available"} +
+ + View Full Video Transcript + + + + + + +
+ {selectedVideo.transcript} +
+
+ {/if} +
+ {:else} +
+ + + +

No Video Selected

+

Click a video from the sidebar list to read its digest highlights.

+
+ {/if} +
+
+
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..603bc47 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{svelte,js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..49bbc97 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +});