Browse Source

stuff

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
56201c5e1d
  1. 71
      .do/app.yaml
  2. 242
      DEPLOYMENT.md
  3. 81
      QUICKSTART.md
  4. 14
      README.md
  5. 39
      backend/src/controllers/videos.controller.ts
  6. 1
      backend/src/db/migrate.ts
  7. 4
      backend/src/index.ts
  8. 11
      backend/src/services/cache.service.ts
  9. 119
      frontend/src/components/Navbar/Navbar.css
  10. 108
      frontend/src/components/Navbar/Navbar.tsx
  11. 24
      frontend/src/hooks/useVideos.ts
  12. 59
      frontend/src/pages/HomePage.tsx
  13. 2
      frontend/src/types/api.ts
  14. 228
      navbar-refactor-plan.md
  15. 148
      pagination-debug-plan.md

71
.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: /

242
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! 🚀

81
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**

14
README.md

@ -208,7 +208,17 @@ The app caches videos for 1 hour by default to minimize API usage.
## Production Deployment ## 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` 1. Build: `npm run build`
2. Start: `npm start` 2. Start: `npm start`
3. Use PM2 or systemd for process management 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 5. Use nginx as reverse proxy
6. Set up SSL certificate (Let's Encrypt) 6. Set up SSL certificate (Let's Encrypt)
### Frontend #### Frontend
1. Build: `npm run build` 1. Build: `npm run build`
2. Serve `dist` folder via nginx or CDN 2. Serve `dist` folder via nginx or CDN
3. Update `VITE_API_URL` to production backend URL 3. Update `VITE_API_URL` to production backend URL

39
backend/src/controllers/videos.controller.ts

@ -1,8 +1,8 @@
import { Response } from 'express'; import { Response } from 'express';
import { AuthRequest } from '../types/index.js'; 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 { 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) { export async function getAllVideos(req: AuthRequest, res: Response) {
try { try {
@ -72,6 +72,17 @@ export async function getAllVideos(req: AuthRequest, res: Response) {
oldestCacheAge = Math.floor((Date.now() - oldestFetch.getTime()) / 60000); 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 => ({ const videos = videosResult.rows.map(row => ({
id: row.id, id: row.id,
channelId: row.channel_id, channelId: row.channel_id,
@ -96,7 +107,9 @@ export async function getAllVideos(req: AuthRequest, res: Response) {
total, total,
totalPages: Math.ceil(total / limitNum), totalPages: Math.ceil(total / limitNum),
hasMore: offset + videos.length < total, hasMore: offset + videos.length < total,
oldestCacheAge oldestCacheAge,
cacheStale: cacheExpired,
refreshing: refreshInProgress
} }
}); });
} catch (error: any) { } 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);
}
}

1
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 ('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 ('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 ('initial_setup_complete', 'false')`);
await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('refresh_in_progress', 'false')`);
} }
} }
]; ];

4
backend/src/index.ts

@ -35,8 +35,8 @@ async function startServer() {
app.use(cookieParser()); app.use(cookieParser());
app.use('/api', apiLimiter); app.use('/api', apiLimiter);
// Health check // Health check (for DigitalOcean)
app.get('/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });

11
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'; import { fetchChannelVideos } from './youtube.service.js';
export async function isRefreshInProgress(): Promise<boolean> {
const value = await getSetting('refresh_in_progress');
return value === 'true';
}
export async function setRefreshInProgress(inProgress: boolean): Promise<void> {
await setSetting('refresh_in_progress', inProgress ? 'true' : 'false');
}
export async function isCacheValid(channelId: string): Promise<boolean> { export async function isCacheValid(channelId: string): Promise<boolean> {
const result = await db.execute({ const result = await db.execute({
sql: `SELECT last_fetched FROM cache_metadata WHERE channel_id = ?`, sql: `SELECT last_fetched FROM cache_metadata WHERE channel_id = ?`,

119
frontend/src/components/Navbar/Navbar.css

@ -83,11 +83,123 @@
background-color: #0556c4; 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) { @media (max-width: 768px) {
.navbar-container { .navbar-container {
padding: 8px 16px; padding: 8px 16px;
} }
.navbar-filters {
padding: 12px 16px;
}
.navbar-menu { .navbar-menu {
gap: 12px; gap: 12px;
} }
@ -99,5 +211,12 @@
.navbar-username { .navbar-username {
display: none; display: none;
} }
.navbar-filter-controls {
gap: 8px;
} }
.navbar-clear-button {
width: 100%;
}
}

108
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 { useAuth } from '../../hooks/useAuth';
import { useChannels } from '../../hooks/useChannels';
import './Navbar.css'; import './Navbar.css';
export function Navbar() { export function Navbar() {
const { isAuthenticated, user, logout } = useAuth(); 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 () => { const handleLogout = async () => {
await logout(); 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<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams);
newParams.set('sort', e.target.value);
newParams.set('page', '1');
setSearchParams(newParams);
};
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
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 ( return (
<nav className="navbar"> <nav className="navbar">
<div className="navbar-container"> <div className="navbar-container">
<Link to="/" className="navbar-logo"> <Link to="/" className="navbar-logo">
<span className="logo-icon">📺</span> <span className="logo-icon">📺</span>
<span className="logo-text">Kiddos</span>
</Link> </Link>
<div className="navbar-menu"> <div className="navbar-menu">
@ -41,7 +93,59 @@ export function Navbar() {
)} )}
</div> </div>
</div> </div>
{isHomePage && (
<div className="navbar-filters">
<div className="navbar-filters-container">
<form onSubmit={handleSearchSubmit} className="navbar-search-form">
<input
type="text"
placeholder="Search videos..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="navbar-search-input"
/>
<button type="submit" className="navbar-search-button">
🔍
</button>
</form>
<div className="navbar-filter-controls">
<select
value={searchParams.get('sort') || 'newest'}
onChange={handleSortChange}
className="navbar-filter-select"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Most Popular</option>
</select>
<select
value={searchParams.get('channel') || ''}
onChange={handleChannelChange}
className="navbar-filter-select"
>
<option value="">All Channels</option>
{channels.map(channel => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
{hasFilters && (
<button
onClick={handleClearFilters}
className="navbar-clear-button"
>
Clear Filters
</button>
)}
</div>
</div>
</div>
)}
</nav> </nav>
); );
} }

24
frontend/src/hooks/useVideos.ts

@ -14,18 +14,23 @@ export function useVideos(params: UseVideosParams = {}) {
const [videos, setVideos] = useState<Video[]>([]); const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [meta, setMeta] = useState({ const [meta, setMeta] = useState({
page: 1, page: 1,
limit: 12, limit: 12,
total: 0, total: 0,
totalPages: 0, totalPages: 0,
hasMore: false, hasMore: false,
oldestCacheAge: 0 oldestCacheAge: 0,
cacheStale: false,
refreshing: false
}); });
const { page, limit, channelId, search, sort } = params; const { page, limit, channelId, search, sort } = params;
useEffect(() => { useEffect(() => {
let pollTimeout: NodeJS.Timeout;
const fetchVideos = async () => { const fetchVideos = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -34,6 +39,14 @@ export function useVideos(params: UseVideosParams = {}) {
const response: any = await videosApi.getAll(params); const response: any = await videosApi.getAll(params);
setVideos(response.data.videos); setVideos(response.data.videos);
setMeta(response.meta); 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) { } catch (err: any) {
setError(err.error?.message || 'Failed to fetch videos'); setError(err.error?.message || 'Failed to fetch videos');
} finally { } finally {
@ -42,8 +55,15 @@ export function useVideos(params: UseVideosParams = {}) {
}; };
fetchVideos(); fetchVideos();
// Cleanup timeout on unmount or when dependencies change
return () => {
if (pollTimeout) {
clearTimeout(pollTimeout);
}
};
}, [page, limit, channelId, search, sort]); }, [page, limit, channelId, search, sort]);
return { videos, loading, error, meta }; return { videos, loading, error, meta, refreshing };
} }

59
frontend/src/pages/HomePage.tsx

@ -1,18 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useVideos } from '../hooks/useVideos'; import { useVideos } from '../hooks/useVideos';
import { useChannels } from '../hooks/useChannels';
import { VideoGrid } from '../components/VideoGrid/VideoGrid'; import { VideoGrid } from '../components/VideoGrid/VideoGrid';
import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer'; import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer';
import { SearchFilter } from '../components/SearchFilter/SearchFilter';
export function HomePage() { export function HomePage() {
const [page, setPage] = useState(1); const [searchParams] = useSearchParams();
const [search, setSearch] = useState('');
const [sort, setSort] = useState<'newest' | 'oldest' | 'popular'>('newest');
const [selectedChannel, setSelectedChannel] = useState<string | undefined>();
const [selectedVideo, setSelectedVideo] = useState<string | null>(null); const [selectedVideo, setSelectedVideo] = useState<string | null>(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, page,
limit: 12, limit: 12,
search: search || undefined, search: search || undefined,
@ -20,37 +22,30 @@ export function HomePage() {
channelId: selectedChannel 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) => { 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' }); window.scrollTo({ top: 0, behavior: 'smooth' });
// Trigger re-render
window.dispatchEvent(new PopStateEvent('popstate'));
}; };
return ( return (
<div> <div>
<SearchFilter {refreshing && (
onSearch={handleSearch} <div style={{
onSortChange={handleSortChange} padding: '12px',
channels={channels} backgroundColor: '#e3f2fd',
selectedChannel={selectedChannel} border: '1px solid #2196f3',
onChannelChange={handleChannelChange} borderRadius: '4px',
/> margin: '16px 24px',
textAlign: 'center',
color: '#1976d2'
}}>
🔄 Fetching latest videos from YouTube...
</div>
)}
<VideoGrid <VideoGrid
videos={videos} videos={videos}

2
frontend/src/types/api.ts

@ -49,6 +49,8 @@ export interface ApiResponse<T = any> {
totalPages?: number; totalPages?: number;
hasMore?: boolean; hasMore?: boolean;
oldestCacheAge?: number; oldestCacheAge?: number;
cacheStale?: boolean;
refreshing?: boolean;
}; };
} }

228
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 `<SearchFilter />` component
- Pass search/filter props to `<Navbar />` 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<string | undefined>();
// ... existing hooks ...
return (
<>
{/* No SearchFilter here anymore */}
<VideoGrid ... />
{selectedVideo && <VideoPlayer ... />}
</>
);
}
```
### 3. Update App.tsx to Pass Props
**File:** `frontend/src/App.tsx`
**Option A - Pass through routes:**
```typescript
<Routes>
<Route
path="/"
element={<HomePage onNavbarPropsChange={setNavbarProps} />}
/>
</Routes>
```
**Option B - Conditional rendering in Navbar:**
```typescript
// Navbar checks current route
const location = useLocation();
const isHomePage = location.pathname === '/';
{isHomePage && (
<div className="navbar-search">
{/* Search and filters */}
</div>
)}
```
**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 (
<Navbar {...navbarProps} />
<Routes>
<Route path="/" element={<HomePage setNavbar={setNavbarProps} />} />
</Routes>
);
}
```
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)

148
pagination-debug-plan.md

@ -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
Loading…
Cancel
Save