From 56201c5e1db06435f6a186e7571276a56578eee6 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Sat, 29 Nov 2025 19:55:02 -0800 Subject: [PATCH] stuff --- .do/app.yaml | 71 ++++++ DEPLOYMENT.md | 242 +++++++++++++++++++ QUICKSTART.md | 81 +++++++ README.md | 14 +- backend/src/controllers/videos.controller.ts | 39 ++- backend/src/db/migrate.ts | 1 + backend/src/index.ts | 4 +- backend/src/services/cache.service.ts | 11 +- frontend/src/components/Navbar/Navbar.css | 121 +++++++++- frontend/src/components/Navbar/Navbar.tsx | 108 ++++++++- frontend/src/hooks/useVideos.ts | 24 +- frontend/src/pages/HomePage.tsx | 59 +++-- frontend/src/types/api.ts | 2 + navbar-refactor-plan.md | 228 +++++++++++++++++ pagination-debug-plan.md | 148 ------------ 15 files changed, 960 insertions(+), 193 deletions(-) create mode 100644 .do/app.yaml create mode 100644 DEPLOYMENT.md create mode 100644 QUICKSTART.md create mode 100644 navbar-refactor-plan.md delete mode 100644 pagination-debug-plan.md diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 0000000..5d551ce --- /dev/null +++ b/.do/app.yaml @@ -0,0 +1,71 @@ +name: kiddos +region: nyc + +# Backend Service +services: + - name: backend + source_dir: backend + github: + repo: YOUR_GITHUB_USERNAME/kiddos + branch: main + deploy_on_push: true + + build_command: npm install && npm run build + run_command: npm run migrate && npm start + + environment_slug: node-js + instance_size_slug: basic-xxs + instance_count: 1 + + http_port: 3000 + + envs: + - key: NODE_ENV + value: production + - key: PORT + value: "3000" + - key: TURSO_URL + value: ${TURSO_URL} + type: SECRET + - key: TURSO_AUTH_TOKEN + value: ${TURSO_AUTH_TOKEN} + type: SECRET + - key: YOUTUBE_API_KEY + value: ${YOUTUBE_API_KEY} + type: SECRET + - key: JWT_SECRET + value: ${JWT_SECRET} + type: SECRET + - key: JWT_REFRESH_SECRET + value: ${JWT_REFRESH_SECRET} + type: SECRET + - key: INITIAL_ADMIN_USERNAME + value: ${INITIAL_ADMIN_USERNAME} + - key: INITIAL_ADMIN_PASSWORD + value: ${INITIAL_ADMIN_PASSWORD} + type: SECRET + - key: CORS_ORIGIN + value: ${APP_URL} + + health_check: + http_path: /api/health + +# Frontend Static Site +static_sites: + - name: frontend + source_dir: frontend + github: + repo: YOUR_GITHUB_USERNAME/kiddos + branch: main + deploy_on_push: true + + build_command: npm install && npm run build + output_dir: dist + + envs: + - key: VITE_API_URL + value: ${backend.PUBLIC_URL} + + routes: + - path: / + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..5819025 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,242 @@ +# Deployment Guide - DigitalOcean App Platform + +This guide will help you deploy the Kiddos app to DigitalOcean's App Platform. + +## Prerequisites + +1. **GitHub Repository**: Push your code to GitHub +2. **DigitalOcean Account**: Create an account at https://digitalocean.com +3. **Turso Database**: Have your Turso credentials ready + - Get from: https://turso.tech +4. **YouTube API Key**: Get from Google Cloud Console + - Visit: https://console.cloud.google.com + - Enable YouTube Data API v3 + - Create API key + +## Step 1: Prepare Your Repository + +1. **Update `.do/app.yaml`**: + ```bash + # Edit .do/app.yaml + # Replace YOUR_GITHUB_USERNAME/kiddos with your actual GitHub repo + # Example: johndoe/kiddos + ``` + +2. **Commit and push to GitHub**: + ```bash + git add . + git commit -m "Add DigitalOcean deployment config" + git push origin main + ``` + +## Step 2: Create App on DigitalOcean + +1. Go to https://cloud.digitalocean.com/apps +2. Click **"Create App"** +3. Select **"GitHub"** as source +4. Authorize DigitalOcean to access your repository +5. Select your `kiddos` repository +6. Choose your branch (usually `main`) +7. DigitalOcean will detect `.do/app.yaml` automatically +8. Click **"Next"** + +## Step 3: Configure Environment Variables + +DigitalOcean will prompt you to fill in the required environment variables (marked as `${VARIABLE}` in app.yaml). + +### Required Secrets: + +1. **TURSO_URL** + - Your Turso database URL + - Example: `libsql://your-database.turso.io` + +2. **TURSO_AUTH_TOKEN** + - Your Turso authentication token + - Get from Turso dashboard + +3. **YOUTUBE_API_KEY** + - Your YouTube Data API v3 key + - Get from Google Cloud Console + +4. **JWT_SECRET** + - A long random string (32+ characters) + - Generate with: `openssl rand -base64 32` + +5. **JWT_REFRESH_SECRET** + - A different long random string (32+ characters) + - Generate with: `openssl rand -base64 32` + +6. **INITIAL_ADMIN_USERNAME** + - Your admin username (e.g., "admin") + +7. **INITIAL_ADMIN_PASSWORD** + - A secure password for admin account + - Make it strong! + +### Optional Variables: + +These will be auto-populated by DigitalOcean: +- `APP_URL` - Automatically set to your frontend URL +- `backend.PUBLIC_URL` - Automatically set to your backend URL + +## Step 4: Review and Deploy + +1. Review the configuration: + - **Backend**: Node.js service on Basic XXS ($5/month) + - **Frontend**: Static site (FREE) + +2. Choose your datacenter region (default: NYC) + +3. Click **"Create Resources"** + +4. Wait for deployment (usually 5-10 minutes) + +## Step 5: Post-Deployment + +### Get Your URLs + +After deployment completes, you'll have: +- **Frontend URL**: `https://kiddos-xxxxx.ondigitalocean.app` +- **Backend URL**: `https://kiddos-backend-xxxxx.ondigitalocean.app` + +### Update CORS Settings (if needed) + +If you have multiple domains or custom domain: + +1. Go to your app in DigitalOcean +2. Click on **"backend"** component +3. Go to **"Environment Variables"** +4. Update `CORS_ORIGIN` to include your custom domain +5. Redeploy + +## Step 6: Test Your Deployment + +1. Visit your frontend URL +2. You should see the video grid +3. Click **"Login"** +4. Use your admin credentials +5. Go to **"Admin"** page +6. Try adding a YouTube channel + +## Troubleshooting + +### Backend Fails to Start + +**Check logs:** +1. Go to your app in DigitalOcean +2. Click on **"backend"** component +3. Go to **"Runtime Logs"** + +**Common issues:** +- Missing environment variables +- Invalid Turso credentials +- Invalid YouTube API key + +### Frontend Shows API Errors + +**Check:** +1. Frontend environment variables +2. Backend is running (check backend URL) +3. CORS is configured correctly +4. Network tab in browser DevTools + +### Database Migration Fails + +**Solution:** +1. Check Turso database is accessible +2. Verify `TURSO_URL` and `TURSO_AUTH_TOKEN` +3. Check backend logs for specific error + +## Monitoring + +### View Logs + +**Backend logs:** +``` +App Platform → Your App → backend → Runtime Logs +``` + +**Build logs:** +``` +App Platform → Your App → backend → Build Logs +``` + +### View Metrics + +``` +App Platform → Your App → Insights +``` + +Shows: +- CPU usage +- Memory usage +- Request count +- Response times + +## Updating Your App + +### Automatic Deployments + +When you push to your GitHub repository, DigitalOcean will automatically: +1. Detect the change +2. Build your app +3. Run tests (if configured) +4. Deploy new version + +### Manual Deployments + +1. Go to your app in DigitalOcean +2. Click **"Create Deployment"** +3. Select branch +4. Click **"Deploy"** + +## Custom Domain (Optional) + +1. Go to your app → **Settings** → **Domains** +2. Click **"Add Domain"** +3. Enter your domain name +4. Follow DNS configuration instructions +5. Wait for SSL certificate provisioning + +## Cost Breakdown + +- **Backend (Basic XXS)**: $5/month +- **Frontend (Static Site)**: FREE +- **Turso Database**: FREE tier (up to 9 GB) +- **Bandwidth**: 100 GB/month included + +**Total: ~$5/month** + +## Scaling + +To upgrade: +1. Go to your app +2. Click on **"backend"** component +3. Go to **"Resources"** +4. Choose a larger instance size +5. Click **"Save"** + +Available sizes: +- Basic XXS: $5/month (512 MB RAM) +- Basic XS: $12/month (1 GB RAM) +- Basic S: $25/month (2 GB RAM) + +## Support + +- **DigitalOcean Docs**: https://docs.digitalocean.com/products/app-platform/ +- **Turso Docs**: https://docs.turso.tech +- **YouTube API Docs**: https://developers.google.com/youtube/v3 + +## Security Checklist + +- ✅ All secrets stored as encrypted environment variables +- ✅ CORS configured to only allow your domain +- ✅ JWT tokens use httpOnly cookies +- ✅ Rate limiting enabled on API +- ✅ Admin password is strong +- ✅ HTTPS enabled automatically by DigitalOcean + +--- + +**Ready to deploy?** Follow the steps above and you'll be live in minutes! 🚀 + diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..61706b9 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,81 @@ +# Quick Start - Deploy to DigitalOcean + +**Time to deploy: ~10 minutes** + +## Before You Start + +Gather these items: +- [ ] GitHub repository URL +- [ ] Turso database URL and auth token ([Get from Turso](https://turso.tech)) +- [ ] YouTube API key ([Get from Google](https://console.cloud.google.com)) +- [ ] Admin username and password (you choose these) + +## Deploy in 4 Steps + +### 1. Update Configuration (2 minutes) + +Edit `.do/app.yaml` and replace `YOUR_GITHUB_USERNAME/kiddos` with your actual GitHub repo: + +```yaml +github: + repo: johndoe/kiddos # <-- Change this + branch: main +``` + +### 2. Push to GitHub (1 minute) + +```bash +git add . +git commit -m "Add DigitalOcean deployment config" +git push origin main +``` + +### 3. Create App on DigitalOcean (5 minutes) + +1. Go to https://cloud.digitalocean.com/apps +2. Click **"Create App"** +3. Select **GitHub** and authorize +4. Select your `kiddos` repository +5. Choose `main` branch +6. DigitalOcean detects `.do/app.yaml` ✨ +7. Click **"Next"** + +### 4. Add Environment Variables (2 minutes) + +Fill in these required values: + +``` +TURSO_URL = libsql://your-database.turso.io +TURSO_AUTH_TOKEN = your-token-here +YOUTUBE_API_KEY = your-youtube-api-key +JWT_SECRET = (generate with: openssl rand -base64 32) +JWT_REFRESH_SECRET = (generate with: openssl rand -base64 32) +INITIAL_ADMIN_USERNAME = admin +INITIAL_ADMIN_PASSWORD = your-secure-password +``` + +**Click "Create Resources"** and wait for deployment! + +--- + +## After Deployment + +1. Visit your app URL (shown in DigitalOcean) +2. Login with your admin credentials +3. Add YouTube channels in the admin panel +4. Done! 🎉 + +--- + +## Need Help? + +See [DEPLOYMENT.md](./DEPLOYMENT.md) for detailed instructions and troubleshooting. + +## Cost + +- **$5/month** for backend +- **FREE** for frontend +- **FREE** for database (Turso free tier) + +**Total: $5/month** + diff --git a/README.md b/README.md index d7ebd6d..7712606 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,17 @@ The app caches videos for 1 hour by default to minimize API usage. ## Production Deployment -### Backend +### Deploy to DigitalOcean (Recommended - $5/month) + +**Quick Start**: See [QUICKSTART.md](./QUICKSTART.md) for a 10-minute deployment guide. + +**Detailed Guide**: See [DEPLOYMENT.md](./DEPLOYMENT.md) for complete deployment instructions, troubleshooting, and monitoring. + +The app is pre-configured for one-click deployment to DigitalOcean's App Platform using the included `.do/app.yaml` configuration. + +### Manual Deployment + +#### Backend 1. Build: `npm run build` 2. Start: `npm start` 3. Use PM2 or systemd for process management @@ -216,7 +226,7 @@ The app caches videos for 1 hour by default to minimize API usage. 5. Use nginx as reverse proxy 6. Set up SSL certificate (Let's Encrypt) -### Frontend +#### Frontend 1. Build: `npm run build` 2. Serve `dist` folder via nginx or CDN 3. Update `VITE_API_URL` to production backend URL diff --git a/backend/src/controllers/videos.controller.ts b/backend/src/controllers/videos.controller.ts index 1e8824b..3f84f0d 100644 --- a/backend/src/controllers/videos.controller.ts +++ b/backend/src/controllers/videos.controller.ts @@ -1,8 +1,8 @@ import { Response } from 'express'; import { AuthRequest } from '../types/index.js'; -import { db } from '../config/database.js'; +import { db, getSetting } from '../config/database.js'; import { formatDuration } from '../services/youtube.service.js'; -import { refreshMultipleChannels } from '../services/cache.service.js'; +import { refreshMultipleChannels, isRefreshInProgress, setRefreshInProgress } from '../services/cache.service.js'; export async function getAllVideos(req: AuthRequest, res: Response) { try { @@ -72,6 +72,17 @@ export async function getAllVideos(req: AuthRequest, res: Response) { oldestCacheAge = Math.floor((Date.now() - oldestFetch.getTime()) / 60000); } + // Check cache age and trigger refresh if needed + const cacheDuration = parseInt((await getSetting('cache_duration_minutes')) || '60'); + const cacheExpired = oldestCacheAge > cacheDuration; + const refreshInProgress = await isRefreshInProgress(); + + // Trigger async refresh if cache is expired and not already refreshing + if (cacheExpired && !refreshInProgress) { + // Fire and forget - don't await + refreshAllChannelsAsync(); + } + const videos = videosResult.rows.map(row => ({ id: row.id, channelId: row.channel_id, @@ -96,7 +107,9 @@ export async function getAllVideos(req: AuthRequest, res: Response) { total, totalPages: Math.ceil(total / limitNum), hasMore: offset + videos.length < total, - oldestCacheAge + oldestCacheAge, + cacheStale: cacheExpired, + refreshing: refreshInProgress } }); } catch (error: any) { @@ -157,3 +170,23 @@ export async function refreshVideos(req: AuthRequest, res: Response) { } } +async function refreshAllChannelsAsync() { + try { + await setRefreshInProgress(true); + + // Get all channel IDs + const channels = await db.execute('SELECT id FROM channels'); + const channelIds = channels.rows.map(row => row.id as string); + + if (channelIds.length > 0) { + console.log(`[AUTO-REFRESH] Starting refresh of ${channelIds.length} channels...`); + const result = await refreshMultipleChannels(channelIds, true); + console.log(`[AUTO-REFRESH] Complete: ${result.success} succeeded, ${result.failed} failed`); + } + } catch (error) { + console.error('[AUTO-REFRESH] Error:', error); + } finally { + await setRefreshInProgress(false); + } +} + diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 26f97cc..6b63512 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -94,6 +94,7 @@ const migrations = [ await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('videos_per_channel', '50')`); await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('pagination_size', '12')`); await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('initial_setup_complete', 'false')`); + await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('refresh_in_progress', 'false')`); } } ]; diff --git a/backend/src/index.ts b/backend/src/index.ts index 96358f0..fe1ba49 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,8 +35,8 @@ async function startServer() { app.use(cookieParser()); app.use('/api', apiLimiter); - // Health check - app.get('/health', (req, res) => { + // Health check (for DigitalOcean) + app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); diff --git a/backend/src/services/cache.service.ts b/backend/src/services/cache.service.ts index 239d63c..414aa8a 100644 --- a/backend/src/services/cache.service.ts +++ b/backend/src/services/cache.service.ts @@ -1,6 +1,15 @@ -import { db, getSetting } from '../config/database.js'; +import { db, getSetting, setSetting } from '../config/database.js'; import { fetchChannelVideos } from './youtube.service.js'; +export async function isRefreshInProgress(): Promise { + const value = await getSetting('refresh_in_progress'); + return value === 'true'; +} + +export async function setRefreshInProgress(inProgress: boolean): Promise { + await setSetting('refresh_in_progress', inProgress ? 'true' : 'false'); +} + export async function isCacheValid(channelId: string): Promise { const result = await db.execute({ sql: `SELECT last_fetched FROM cache_metadata WHERE channel_id = ?`, diff --git a/frontend/src/components/Navbar/Navbar.css b/frontend/src/components/Navbar/Navbar.css index 2524033..7fa8b32 100644 --- a/frontend/src/components/Navbar/Navbar.css +++ b/frontend/src/components/Navbar/Navbar.css @@ -83,11 +83,123 @@ background-color: #0556c4; } +/* Search and Filters Section */ +.navbar-filters { + background-color: #f9f9f9; + border-top: 1px solid #e5e5e5; + padding: 12px 24px; +} + +.navbar-filters-container { + max-width: 1600px; + margin: 0 auto; + display: flex; + gap: 16px; + align-items: center; +} + +.navbar-search-form { + flex: 1; + display: flex; + gap: 8px; + max-width: 500px; +} + +.navbar-search-input { + flex: 1; + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 20px; + font-size: 14px; + background-color: #fff; +} + +.navbar-search-input:focus { + outline: none; + border-color: #065fd4; +} + +.navbar-search-button { + padding: 8px 16px; + background-color: #f2f2f2; + border: 1px solid #ccc; + border-radius: 20px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; +} + +.navbar-search-button:hover { + background-color: #e5e5e5; +} + +.navbar-filter-controls { + display: flex; + gap: 12px; + align-items: center; +} + +.navbar-filter-select { + padding: 8px 12px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + background-color: white; + cursor: pointer; +} + +.navbar-filter-select:focus { + outline: none; + border-color: #065fd4; +} + +.navbar-clear-button { + padding: 8px 12px; + background-color: #fff; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + white-space: nowrap; + transition: background-color 0.2s; +} + +.navbar-clear-button:hover { + background-color: #f2f2f2; +} + +/* Mobile Responsive - Second Row Layout */ +@media (max-width: 1024px) { + .navbar-filters-container { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .navbar-search-form { + max-width: 100%; + } + + .navbar-filter-controls { + justify-content: space-between; + flex-wrap: wrap; + } + + .navbar-filter-select { + flex: 1; + min-width: 120px; + } +} + @media (max-width: 768px) { .navbar-container { padding: 8px 16px; } + .navbar-filters { + padding: 12px 16px; + } + .navbar-menu { gap: 12px; } @@ -99,5 +211,12 @@ .navbar-username { display: none; } + + .navbar-filter-controls { + gap: 8px; + } + + .navbar-clear-button { + width: 100%; + } } - diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx index 5f71d73..d8e925a 100644 --- a/frontend/src/components/Navbar/Navbar.tsx +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -1,19 +1,71 @@ -import { Link } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { Link, useLocation, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../hooks/useAuth'; +import { useChannels } from '../../hooks/useChannels'; import './Navbar.css'; export function Navbar() { const { isAuthenticated, user, logout } = useAuth(); + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const { channels } = useChannels(); + + const isHomePage = location.pathname === '/'; + const [searchInput, setSearchInput] = useState(searchParams.get('search') || ''); + + // Sync search input with URL params + useEffect(() => { + setSearchInput(searchParams.get('search') || ''); + }, [searchParams]); const handleLogout = async () => { await logout(); }; + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const newParams = new URLSearchParams(searchParams); + if (searchInput) { + newParams.set('search', searchInput); + } else { + newParams.delete('search'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleSortChange = (e: React.ChangeEvent) => { + const newParams = new URLSearchParams(searchParams); + newParams.set('sort', e.target.value); + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleChannelChange = (e: React.ChangeEvent) => { + const newParams = new URLSearchParams(searchParams); + if (e.target.value) { + newParams.set('channel', e.target.value); + } else { + newParams.delete('channel'); + } + newParams.set('page', '1'); + setSearchParams(newParams); + }; + + const handleClearFilters = () => { + setSearchInput(''); + setSearchParams(new URLSearchParams()); + }; + + const hasFilters = searchParams.get('search') || searchParams.get('channel') || + (searchParams.get('sort') && searchParams.get('sort') !== 'newest'); + return ( ); } - diff --git a/frontend/src/hooks/useVideos.ts b/frontend/src/hooks/useVideos.ts index 59056da..bfc76e0 100644 --- a/frontend/src/hooks/useVideos.ts +++ b/frontend/src/hooks/useVideos.ts @@ -14,18 +14,23 @@ export function useVideos(params: UseVideosParams = {}) { const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); const [meta, setMeta] = useState({ page: 1, limit: 12, total: 0, totalPages: 0, hasMore: false, - oldestCacheAge: 0 + oldestCacheAge: 0, + cacheStale: false, + refreshing: false }); const { page, limit, channelId, search, sort } = params; useEffect(() => { + let pollTimeout: NodeJS.Timeout; + const fetchVideos = async () => { setLoading(true); setError(null); @@ -34,6 +39,14 @@ export function useVideos(params: UseVideosParams = {}) { const response: any = await videosApi.getAll(params); setVideos(response.data.videos); setMeta(response.meta); + setRefreshing(response.meta.refreshing || false); + + // If refreshing, poll after 5 seconds to get fresh data + if (response.meta.refreshing && !response.meta.cacheStale) { + pollTimeout = setTimeout(() => { + fetchVideos(); + }, 5000); + } } catch (err: any) { setError(err.error?.message || 'Failed to fetch videos'); } finally { @@ -42,8 +55,15 @@ export function useVideos(params: UseVideosParams = {}) { }; fetchVideos(); + + // Cleanup timeout on unmount or when dependencies change + return () => { + if (pollTimeout) { + clearTimeout(pollTimeout); + } + }; }, [page, limit, channelId, search, sort]); - return { videos, loading, error, meta }; + return { videos, loading, error, meta, refreshing }; } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 22bfb47..f5fa61a 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,18 +1,20 @@ import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useVideos } from '../hooks/useVideos'; -import { useChannels } from '../hooks/useChannels'; import { VideoGrid } from '../components/VideoGrid/VideoGrid'; import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer'; -import { SearchFilter } from '../components/SearchFilter/SearchFilter'; export function HomePage() { - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [sort, setSort] = useState<'newest' | 'oldest' | 'popular'>('newest'); - const [selectedChannel, setSelectedChannel] = useState(); + const [searchParams] = useSearchParams(); const [selectedVideo, setSelectedVideo] = useState(null); - const { videos, loading, error, meta } = useVideos({ + // Read from URL query params + const page = parseInt(searchParams.get('page') || '1', 10); + const search = searchParams.get('search') || ''; + const sort = (searchParams.get('sort') || 'newest') as 'newest' | 'oldest' | 'popular'; + const selectedChannel = searchParams.get('channel') || undefined; + + const { videos, loading, error, meta, refreshing } = useVideos({ page, limit: 12, search: search || undefined, @@ -20,37 +22,30 @@ export function HomePage() { channelId: selectedChannel }); - const { channels } = useChannels(); - - const handleSearch = (query: string) => { - setSearch(query); - setPage(1); - }; - - const handleSortChange = (newSort: 'newest' | 'oldest' | 'popular') => { - setSort(newSort); - setPage(1); - }; - - const handleChannelChange = (channelId: string | undefined) => { - setSelectedChannel(channelId); - setPage(1); - }; - const handlePageChange = (newPage: number) => { - setPage(newPage); + const newParams = new URLSearchParams(searchParams); + newParams.set('page', newPage.toString()); + window.history.pushState({}, '', `?${newParams.toString()}`); window.scrollTo({ top: 0, behavior: 'smooth' }); + // Trigger re-render + window.dispatchEvent(new PopStateEvent('popstate')); }; return (
- + {refreshing && ( +
+ 🔄 Fetching latest videos from YouTube... +
+ )} { totalPages?: number; hasMore?: boolean; oldestCacheAge?: number; + cacheStale?: boolean; + refreshing?: boolean; }; } diff --git a/navbar-refactor-plan.md b/navbar-refactor-plan.md new file mode 100644 index 0000000..763afcd --- /dev/null +++ b/navbar-refactor-plan.md @@ -0,0 +1,228 @@ +# Move Search & Filters to Navbar - Refactoring Plan + +## Goal +Move the search bar and filter controls from the SearchFilter component into the Navbar for a cleaner, more YouTube-like interface. + +## Current Structure + +### Files Involved: +1. `frontend/src/components/Navbar/Navbar.tsx` - Navigation bar (Home, Admin, Login/Logout) +2. `frontend/src/components/SearchFilter/SearchFilter.tsx` - Search and filters +3. `frontend/src/pages/HomePage.tsx` - Manages state and passes to SearchFilter + +### Current Flow: +``` +HomePage (manages state) + ├── Navbar (navigation only) + └── SearchFilter (search, sort, channel filter) + └── VideoGrid +``` + +## Proposed Structure + +### New Flow: +``` +App + └── Navbar (navigation + search + filters) +HomePage + └── VideoGrid (just videos and pagination) +``` + +## Changes Required + +### 1. Update Navbar Component +**File:** `frontend/src/components/Navbar/Navbar.tsx` + +**Add props:** +```typescript +interface NavbarProps { + // Search and filter props + onSearch?: (query: string) => void; + onSortChange?: (sort: 'newest' | 'oldest' | 'popular') => void; + onChannelChange?: (channelId: string | undefined) => void; + channels?: Array<{ id: string; name: string }>; + selectedChannel?: string; + currentSearch?: string; + + // Only show search/filters on home page + showSearch?: boolean; +} +``` + +**Add to navbar:** +- Search input (centered or right side) +- Sort dropdown +- Channel filter dropdown +- Clear filters button (only if filters active) + +**Layout considerations:** +- Mobile: Collapse to hamburger menu or second row +- Desktop: Horizontal layout after logo/nav links + +### 2. Update HomePage +**File:** `frontend/src/pages/HomePage.tsx` + +**Changes:** +- Remove `` component +- Pass search/filter props to `` instead +- Keep state management in HomePage +- Navbar becomes "controlled component" receiving callbacks + +**New structure:** +```typescript +export function HomePage() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState<'newest' | 'oldest' | 'popular'>('newest'); + const [selectedChannel, setSelectedChannel] = useState(); + + // ... existing hooks ... + + return ( + <> + {/* No SearchFilter here anymore */} + + {selectedVideo && } + + ); +} +``` + +### 3. Update App.tsx to Pass Props +**File:** `frontend/src/App.tsx` + +**Option A - Pass through routes:** +```typescript + + } + /> + +``` + +**Option B - Conditional rendering in Navbar:** +```typescript +// Navbar checks current route +const location = useLocation(); +const isHomePage = location.pathname === '/'; + +{isHomePage && ( +
+ {/* Search and filters */} +
+)} +``` + +**Recommended:** Option B is simpler + +### 4. Handle State Communication + +**Challenge:** HomePage has the state, but Navbar needs to trigger changes. + +**Solution:** Use React Router's `useLocation` and `useNavigate` with URL query params: + +```typescript +// In HomePage +const [searchParams, setSearchParams] = useSearchParams(); +const page = searchParams.get('page') || '1'; +const search = searchParams.get('search') || ''; +const sort = searchParams.get('sort') || 'newest'; + +// In Navbar +const handleSearch = (query: string) => { + const newParams = new URLSearchParams(window.location.search); + newParams.set('search', query); + newParams.set('page', '1'); + navigate({ search: newParams.toString() }); +}; +``` + +**Benefits:** +- Shareable URLs with filters +- Browser back/forward works +- No prop drilling needed + +### 5. Update Navbar CSS +**File:** `frontend/src/components/Navbar/Navbar.css` + +**Add styles for:** +- Search input container +- Filter dropdowns +- Clear button +- Responsive breakpoints +- YouTube-inspired styling + +**Layout:** +``` +Desktop: +[📺 Kiddos] [Home] [Admin] [🔍 Search...] [Sort ▼] [Channel ▼] [Clear] [Login] + +Mobile: +[📺 Kiddos] [☰] +[🔍 Search...] [Sort ▼] [Channel ▼] [Clear] +``` + +### 6. Remove SearchFilter Component (Optional) +**Files to delete/deprecate:** +- `frontend/src/components/SearchFilter/SearchFilter.tsx` +- `frontend/src/components/SearchFilter/SearchFilter.css` + +Or keep it for reuse elsewhere. + +## Implementation Steps + +1. ✅ Create this plan +2. Add URL query param management to HomePage (using `useSearchParams`) +3. Update Navbar to accept search/filter props +4. Add search/filter UI to Navbar component +5. Style Navbar with new layout +6. Update HomePage to remove SearchFilter and pass props to Navbar +7. Test on desktop and mobile +8. Remove SearchFilter component if no longer needed +9. Clean up unused CSS + +## Testing Checklist + +- [ ] Search works from navbar +- [ ] Sort dropdown changes video order +- [ ] Channel filter works +- [ ] Clear filters button appears when filters active +- [ ] Clear filters button resets everything +- [ ] Pagination resets to 1 when filters change +- [ ] URL updates with query params +- [ ] Browser back/forward works with filters +- [ ] Mobile responsive (hamburger menu or stacked layout) +- [ ] Navbar doesn't show search on Admin/Login pages + +## Alternative: Simpler Approach + +If URL params are too complex, keep state in HomePage and pass callbacks to Navbar: + +```typescript +// App.tsx +function App() { + const [navbarProps, setNavbarProps] = useState({}); + + return ( + + + } /> + + ); +} +``` + +But this requires lifting state to App level and prop drilling. + +## Recommendation + +**Best approach:** +1. Use URL query params for filter state +2. Navbar reads from URL and updates URL on changes +3. HomePage reads from URL for fetching videos +4. Clean, shareable, no prop drilling + +**Estimated time:** 30-45 minutes +**Complexity:** Medium (URL params + responsive styling) + diff --git a/pagination-debug-plan.md b/pagination-debug-plan.md deleted file mode 100644 index 4c1152f..0000000 --- a/pagination-debug-plan.md +++ /dev/null @@ -1,148 +0,0 @@ -# Pagination Bug Investigation Plan - -## Problem Statement -The `meta.page` property in API responses always shows `1`, even when requesting page 2, 3, etc. - -## Request Flow Analysis - -### Step 1: Frontend sends request -**File:** `frontend/src/services/apiClient.ts` line 113 -```typescript -getAll: (params?: any) => api.get('/videos', { params }) -``` -**What happens:** -- Axios converts params object to query string -- Request: `GET /api/videos?page=2&limit=12&sort=newest` - -### Step 2: Express receives request -**What happens:** -- Express parses query string into `req.query` object -- All values are **strings**: `{ page: '2', limit: '12', sort: 'newest' }` - -### Step 3: Validation Middleware -**File:** `backend/src/middleware/validation.ts` line 21-30 -```typescript -const validated = schema.parse(req.body || req.query); -if (req.method === 'GET') { - req.query = validated as any; -} -``` - -**Schema:** `backend/src/middleware/validation.ts` line 14 -```typescript -page: z.coerce.number().int().min(1).default(1) -``` - -**POTENTIAL BUG #1:** -- `.default(1)` only applies when value is `undefined` -- If page='2' (string), Zod should: - 1. Check if undefined → NO (it's '2') - 2. Coerce to number → page becomes 2 - 3. Validate int and min(1) → passes - 4. Result: `validated = { page: 2, ... }` - -**QUESTION:** Is req.query being properly replaced? - -### Step 4: Controller receives request -**File:** `backend/src/controllers/videos.controller.ts` line 10-13 -```typescript -const { page = 1, limit = 12, channelId, search, sort = 'newest' } = req.query as any; - -const pageNum = page as number; -const limitNum = limit as number; -``` - -**POTENTIAL BUG #2:** -- Destructuring defaults (= 1, = 12) only apply if value is `undefined` -- After validation, `page` should be a number (not undefined) -- So `pageNum` should equal whatever `page` is - -**QUESTION:** Is `req.query.page` actually the validated number? - -### Step 5: Response -**File:** `backend/src/controllers/videos.controller.ts` line 87-98 -```typescript -res.json({ - success: true, - data: { videos }, - meta: { - page: pageNum, // Should be 2 if we requested page 2 - ... - } -}); -``` - -## Root Cause Hypotheses - -### Hypothesis 1: Validation middleware not working -**Evidence needed:** -- Add `console.log('Before validation:', req.query)` before line 24 in validation.ts -- Add `console.log('After validation:', validated)` after line 24 in validation.ts -- Check if validation is even running - -### Hypothesis 2: req.query not being replaced -**Evidence needed:** -- Add `console.log('req.query in controller:', req.query)` at line 11 in videos.controller.ts -- Check if req.query has numbers or strings -- Check if req.query.page is actually 2 when we request page 2 - -### Hypothesis 3: Type coercion issue -**Evidence needed:** -- Add `console.log('pageNum:', pageNum, 'type:', typeof pageNum)` at line 14 in videos.controller.ts -- Check if pageNum is actually a number or if it's somehow being converted back to default - -### Hypothesis 4: Multiple requests interfering -**Evidence needed:** -- Check browser network tab to see if there are duplicate requests -- One request might be page 2, another might be page 1 -- Frontend might be showing response from wrong request - -## Debugging Steps - -1. **Add logging to validation middleware** - - Log `req.query` before validation - - Log `validated` result after validation - - Verify Zod is correctly converting and not defaulting - -2. **Add logging to controller** - - Log `req.query` when controller receives it - - Log `page`, `pageNum`, `offset` calculations - - Verify the response meta.page value - -3. **Check browser network tab** - - Verify the request URL includes correct page parameter - - Verify the response meta.page value - - Check if there are multiple simultaneous requests - -4. **Test with direct curl** - - `curl "http://localhost:3000/api/videos?page=2&limit=12"` - - See if backend returns correct page in meta - - This isolates frontend vs backend issue - -## Expected Behavior - -Request: `GET /api/videos?page=2` -Response: -```json -{ - "success": true, - "data": { "videos": [...] }, - "meta": { - "page": 2, // Should be 2! - "limit": 12, - "total": 60, - "totalPages": 5, - ... - } -} -``` - -## Action Items - -1. Add debug logging to both validation middleware and controller -2. Test with page 2 request and check all console.logs -3. Based on logs, identify which hypothesis is correct -4. Fix the actual bug -5. Remove debug logging -6. Test pagination works correctly -