Browse Source

working initial commit

drawing-pad
Stephanie Gredell 2 months ago
commit
0c64390062
  1. 43
      .gitignore
  2. 248
      README.md
  3. 17
      backend/.env.example
  4. 3145
      backend/package-lock.json
  5. 36
      backend/package.json
  6. 31
      backend/src/config/database.ts
  7. 60
      backend/src/config/env.ts
  8. 190
      backend/src/controllers/auth.controller.ts
  9. 273
      backend/src/controllers/channels.controller.ts
  10. 159
      backend/src/controllers/videos.controller.ts
  11. 130
      backend/src/db/migrate.ts
  12. 65
      backend/src/index.ts
  13. 44
      backend/src/middleware/auth.ts
  14. 24
      backend/src/middleware/errorHandler.ts
  15. 30
      backend/src/middleware/rateLimiter.ts
  16. 49
      backend/src/middleware/validation.ts
  17. 15
      backend/src/routes/auth.routes.ts
  18. 17
      backend/src/routes/channels.routes.ts
  19. 15
      backend/src/routes/videos.routes.ts
  20. 92
      backend/src/services/auth.service.ts
  21. 140
      backend/src/services/cache.service.ts
  22. 148
      backend/src/services/youtube.service.ts
  23. 29
      backend/src/setup/initialSetup.ts
  24. 61
      backend/src/types/index.ts
  25. 19
      backend/tsconfig.json
  26. 14
      frontend/index.html
  27. 2003
      frontend/package-lock.json
  28. 25
      frontend/package.json
  29. 61
      frontend/src/App.css
  30. 40
      frontend/src/App.tsx
  31. 174
      frontend/src/components/ChannelManager/ChannelManager.css
  32. 114
      frontend/src/components/ChannelManager/ChannelManager.tsx
  33. 42
      frontend/src/components/ErrorBoundary.tsx
  34. 103
      frontend/src/components/Navbar/Navbar.css
  35. 47
      frontend/src/components/Navbar/Navbar.tsx
  36. 21
      frontend/src/components/ProtectedRoute.tsx
  37. 110
      frontend/src/components/SearchFilter/SearchFilter.css
  38. 82
      frontend/src/components/SearchFilter/SearchFilter.tsx
  39. 103
      frontend/src/components/VideoCard/VideoCard.css
  40. 63
      frontend/src/components/VideoCard/VideoCard.tsx
  41. 155
      frontend/src/components/VideoGrid/VideoGrid.css
  42. 119
      frontend/src/components/VideoGrid/VideoGrid.tsx
  43. 81
      frontend/src/components/VideoPlayer/VideoPlayer.css
  44. 44
      frontend/src/components/VideoPlayer/VideoPlayer.tsx
  45. 71
      frontend/src/hooks/useAuth.tsx
  46. 55
      frontend/src/hooks/useChannels.ts
  47. 49
      frontend/src/hooks/useVideos.ts
  48. 10
      frontend/src/main.tsx
  49. 25
      frontend/src/pages/AdminPage.css
  50. 16
      frontend/src/pages/AdminPage.tsx
  51. 74
      frontend/src/pages/HomePage.tsx
  52. 99
      frontend/src/pages/LoginPage.css
  53. 74
      frontend/src/pages/LoginPage.tsx
  54. 121
      frontend/src/services/apiClient.ts
  55. 54
      frontend/src/types/api.ts
  56. 22
      frontend/tsconfig.json
  57. 11
      frontend/tsconfig.node.json
  58. 10
      frontend/vite.config.ts
  59. 372
      package-lock.json
  60. 17
      package.json
  61. 148
      pagination-debug-plan.md

43
.gitignore vendored

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Build outputs
dist/
build/
*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Testing
coverage/
# Misc
.turso/

248
README.md

@ -0,0 +1,248 @@ @@ -0,0 +1,248 @@
# Kiddos - YouTube Channel Video Aggregator
A full-stack application for aggregating and displaying videos from multiple YouTube channels with a protected admin dashboard.
## Tech Stack
**Frontend:**
- React 18 with TypeScript
- Vite for build tooling
- React Router for navigation
- Axios for API calls
**Backend:**
- Node.js with Express
- TypeScript
- Turso (SQLite) database
- JWT authentication with refresh tokens
- bcrypt for password hashing
- YouTube Data API v3
## Features
- 📺 Display videos from multiple YouTube channels
- 🔍 Search and filter videos by channel, date, or popularity
- 🎬 Embedded video player
- 🔐 Protected admin dashboard with JWT authentication
- ⚡ Video caching to reduce YouTube API quota usage
- 📱 Fully responsive design (YouTube-inspired UI)
- ♻ Token refresh mechanism for seamless authentication
## Setup Instructions
### Prerequisites
- Node.js 18+ installed
- Turso CLI installed
- YouTube Data API v3 key
### 1. Set Up Turso Database
```bash
# Install Turso CLI
curl -sSfL https://get.tur.so/install.sh | bash
# Create database
turso db create kiddos-db
# Get database URL
turso db show kiddos-db
# Create auth token
turso db tokens create kiddos-db
```
### 2. Backend Setup
```bash
cd backend
# Install dependencies
npm install
# Create .env file from example
cp .env.example .env
# Edit .env with your values:
# - TURSO_URL (from turso db show)
# - TURSO_AUTH_TOKEN (from turso db tokens create)
# - YOUTUBE_API_KEY (from Google Cloud Console)
# - JWT_SECRET (generate a random 32+ character string)
# - JWT_REFRESH_SECRET (generate another random 32+ character string)
# - INITIAL_ADMIN_USERNAME (e.g., "admin")
# - INITIAL_ADMIN_PASSWORD (choose a secure password)
# Run migrations
npm run migrate
# Start development server
npm run dev
```
Backend will run on http://localhost:3000
### 3. Frontend Setup
```bash
cd frontend
# Install dependencies
npm install
# Create .env file from example
cp .env.example .env
# Edit .env with:
# VITE_API_URL=http://localhost:3000/api
# Start development server
npm run dev
```
Frontend will run on http://localhost:5173
### 4. Run Both Concurrently (Optional)
From the root directory:
```bash
# Install concurrently
npm install
# Run both frontend and backend
npm run dev
```
## Usage
1. **Access the App**: Navigate to http://localhost:5173
2. **View Videos**: The homepage displays all videos from configured channels (public access)
3. **Admin Login**: Click "Login" and use the credentials you set in the backend .env file
4. **Add Channels**: Once logged in, go to the Admin page to add YouTube channels
- Supports channel IDs (UC...), @handles, or full YouTube URLs
5. **Search & Filter**: Use the search bar and filters on the homepage to find specific videos
## API Endpoints
### Authentication
- `POST /api/auth/login` - Login with username/password
- `POST /api/auth/refresh` - Refresh access token
- `POST /api/auth/logout` - Logout and revoke refresh token
- `GET /api/auth/me` - Get current user info
### Channels (Admin protected)
- `GET /api/channels` - Get all channels (public)
- `POST /api/channels` - Add new channel (protected)
- `DELETE /api/channels/:id` - Remove channel (protected)
- `PUT /api/channels/:id/refresh` - Refresh channel data (protected)
### Videos
- `GET /api/videos` - Get videos with pagination/search/filters (public)
- `POST /api/videos/refresh` - Force refresh video cache (protected)
## Environment Variables
### Backend (.env)
```bash
# Required
TURSO_URL=libsql://your-database.turso.io
TURSO_AUTH_TOKEN=your-auth-token-here
YOUTUBE_API_KEY=your-youtube-api-key
JWT_SECRET=your-secret-key-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-different-from-above
# Optional (with defaults)
PORT=3000
CORS_ORIGIN=http://localhost:5173
NODE_ENV=development
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
# Initial admin (required on first run)
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=change-this-secure-password
```
### Frontend (.env)
```bash
VITE_API_URL=http://localhost:3000/api
```
## YouTube API Key Setup
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable "YouTube Data API v3"
4. Create credentials (API Key)
5. Restrict the key to YouTube Data API v3
6. Copy the API key to your backend .env file
## Security Features
- ✅ Passwords hashed with bcrypt (cost factor: 10)
- ✅ JWT access tokens (15 min expiry)
- ✅ Refresh tokens (7 day expiry, stored in database)
- ✅ httpOnly cookies for refresh tokens
- ✅ CORS configured for specific origins
- ✅ Rate limiting on login and API endpoints
- ✅ Input validation with Zod
- ✅ Parameterized SQL queries (SQL injection prevention)
## Performance Optimizations
- Video caching (1-hour default) to reduce YouTube API calls
- Parallel channel fetching using Promise.allSettled
- Database indexing on frequently queried fields
- Pagination to prevent loading all videos at once
- YouTube's CDN for optimized thumbnails
## YouTube API Quota
The free tier provides 10,000 units/day:
- Channel info request: ~1 unit
- Video list request: ~3-5 units
The app caches videos for 1 hour by default to minimize API usage.
## Production Deployment
### Backend
1. Build: `npm run build`
2. Start: `npm start`
3. Use PM2 or systemd for process management
4. Set `NODE_ENV=production` in environment
5. Use nginx as reverse proxy
6. Set up SSL certificate (Let's Encrypt)
### Frontend
1. Build: `npm run build`
2. Serve `dist` folder via nginx or CDN
3. Update `VITE_API_URL` to production backend URL
## Troubleshooting
**Backend won't start:**
- Check all required environment variables are set
- Verify Turso database URL and auth token are correct
- Ensure YouTube API key is valid
**Can't add channels:**
- Verify you're logged in as admin
- Check YouTube API quota hasn't been exceeded
- Ensure channel ID/URL format is correct
**Videos not showing:**
- Add channels from admin dashboard first
- Wait a moment for initial video fetch
- Check backend logs for errors
## License
MIT
## Contributing
Pull requests are welcome! Please ensure all tests pass and follow the existing code style.

17
backend/.env.example

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
# Required
TURSO_URL=libsql://your-database.turso.io
TURSO_AUTH_TOKEN=your-auth-token-here
YOUTUBE_API_KEY=your-youtube-api-key
JWT_SECRET=your-secret-key-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-different-from-above
# Optional (with defaults)
PORT=3000
CORS_ORIGIN=http://localhost:5173
NODE_ENV=development
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
# Initial admin (required on first run)
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=change-this-secure-password

3145
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

36
backend/package.json

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
{
"name": "kiddos-backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "nodemon --exec tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"migrate": "tsx src/db/migrate.ts",
"seed": "tsx src/db/seed.ts"
},
"dependencies": {
"express": "^4.18.2",
"@libsql/client": "^0.4.0",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"zod": "^3.22.4",
"express-rate-limit": "^7.1.5"
},
"devDependencies": {
"typescript": "^5.3.3",
"@types/express": "^4.17.21",
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.5",
"@types/cookie-parser": "^1.4.6",
"@types/cors": "^2.8.17",
"@types/node": "^20.10.5",
"tsx": "^4.7.0",
"nodemon": "^3.0.2"
}
}

31
backend/src/config/database.ts

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
import { createClient } from '@libsql/client';
import { env } from './env.js';
export const db = createClient({
url: env.tursoUrl,
authToken: env.tursoAuthToken
});
// Helper function for getting settings
export async function getSetting(key: string): Promise<string | null> {
const result = await db.execute({
sql: 'SELECT value FROM settings WHERE key = ?',
args: [key]
});
if (result.rows.length === 0) {
return null;
}
return result.rows[0].value as string;
}
// Helper function for setting values
export async function setSetting(key: string, value: string): Promise<void> {
await db.execute({
sql: `INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, ?)`,
args: [key, value, new Date().toISOString()]
});
}

60
backend/src/config/env.ts

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
import dotenv from 'dotenv';
dotenv.config();
const requiredEnvVars = [
'TURSO_URL',
'TURSO_AUTH_TOKEN',
'YOUTUBE_API_KEY',
'JWT_SECRET',
'JWT_REFRESH_SECRET'
] as const;
const optionalEnvVars = {
PORT: '3000',
CORS_ORIGIN: 'http://localhost:5173',
NODE_ENV: 'development',
ACCESS_TOKEN_EXPIRY: '15m',
REFRESH_TOKEN_EXPIRY: '7d',
INITIAL_ADMIN_USERNAME: 'admin'
} as const;
export function validateEnv() {
const missing: string[] = [];
for (const varName of requiredEnvVars) {
if (!process.env[varName]) {
missing.push(varName);
}
}
if (missing.length > 0) {
console.error('❌ Missing required environment variables:');
missing.forEach(v => console.error(` - ${v}`));
process.exit(1);
}
// Set defaults for optional vars
for (const [key, defaultValue] of Object.entries(optionalEnvVars)) {
if (!process.env[key]) {
process.env[key] = defaultValue;
}
}
console.log('✓ Environment variables validated');
}
export const env = {
tursoUrl: process.env.TURSO_URL!,
tursoAuthToken: process.env.TURSO_AUTH_TOKEN!,
youtubeApiKey: process.env.YOUTUBE_API_KEY!,
jwtSecret: process.env.JWT_SECRET!,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET!,
port: parseInt(process.env.PORT || '3000'),
corsOrigin: process.env.CORS_ORIGIN!,
nodeEnv: process.env.NODE_ENV!,
accessTokenExpiry: process.env.ACCESS_TOKEN_EXPIRY!,
refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY!,
initialAdminUsername: process.env.INITIAL_ADMIN_USERNAME,
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD
};

190
backend/src/controllers/auth.controller.ts

@ -0,0 +1,190 @@ @@ -0,0 +1,190 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js';
import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword } from '../services/auth.service.js';
import { env } from '../config/env.js';
import jwt from 'jsonwebtoken';
export async function login(req: AuthRequest, res: Response) {
try {
const { username, password } = req.body;
// Find user
const result = await db.execute({
sql: 'SELECT * FROM users WHERE username = ?',
args: [username]
});
if (!result.rows.length) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: 'Invalid username or password'
}
});
}
const user = result.rows[0];
// Verify password
const valid = await verifyPassword(password, user.password_hash as string);
if (!valid) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_CREDENTIALS',
message: 'Invalid username or password'
}
});
}
// Update last login
await db.execute({
sql: 'UPDATE users SET last_login = ? WHERE id = ?',
args: [new Date().toISOString(), user.id]
});
// Create tokens
const { accessToken, refreshToken } = await createTokens(
user.id as number,
user.username as string
);
// Set refresh token as httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({
success: true,
data: {
user: {
id: user.id,
username: user.username
},
accessToken,
refreshToken
}
});
} catch (error: any) {
console.error('Login error:', error);
res.status(500).json({
success: false,
error: {
code: 'LOGIN_ERROR',
message: 'An error occurred during login'
}
});
}
}
export async function refresh(req: AuthRequest, res: Response) {
try {
const refreshToken = req.cookies.refresh_token || req.body.refreshToken;
if (!refreshToken) {
return res.status(401).json({
success: false,
error: {
code: 'NO_REFRESH_TOKEN',
message: 'Refresh token not provided'
}
});
}
const { accessToken } = await refreshAccessToken(refreshToken);
res.json({
success: true,
data: { accessToken }
});
} catch (error: any) {
res.status(401).json({
success: false,
error: {
code: 'REFRESH_ERROR',
message: error.message || 'Failed to refresh token'
}
});
}
}
export async function logout(req: AuthRequest, res: Response) {
try {
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
try {
const decoded = jwt.verify(refreshToken, env.jwtRefreshSecret) as { token: string };
await revokeRefreshToken(decoded.token);
} catch (error) {
// Token might be invalid, but we still clear the cookie
console.error('Error revoking token:', error);
}
}
// Clear cookie
res.clearCookie('refresh_token', {
httpOnly: true,
secure: env.nodeEnv === 'production',
sameSite: 'strict',
path: '/'
});
res.json({ success: true });
} catch (error: any) {
console.error('Logout error:', error);
res.status(500).json({
success: false,
error: {
code: 'LOGOUT_ERROR',
message: 'Error during logout'
}
});
}
}
export async function getCurrentUser(req: AuthRequest, res: Response) {
try {
const result = await db.execute({
sql: 'SELECT id, username, last_login FROM users WHERE id = ?',
args: [req.userId]
});
if (!result.rows.length) {
return res.status(404).json({
success: false,
error: {
code: 'USER_NOT_FOUND',
message: 'User not found'
}
});
}
const user = result.rows[0];
res.json({
success: true,
data: {
id: user.id,
username: user.username,
lastLogin: user.last_login
}
});
} catch (error: any) {
console.error('Get user error:', error);
res.status(500).json({
success: false,
error: {
code: 'GET_USER_ERROR',
message: 'Error fetching user data'
}
});
}
}

273
backend/src/controllers/channels.controller.ts

@ -0,0 +1,273 @@ @@ -0,0 +1,273 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js';
import { fetchChannelInfo, fetchChannelVideos } from '../services/youtube.service.js';
export async function getAllChannels(req: AuthRequest, res: Response) {
try {
const result = await db.execute(`
SELECT c.*, cm.last_fetched, cm.fetch_error
FROM channels c
LEFT JOIN cache_metadata cm ON c.id = cm.channel_id
ORDER BY c.added_at DESC
`);
res.json({
success: true,
data: {
channels: result.rows.map(row => ({
id: row.id,
name: row.name,
customUrl: row.custom_url,
thumbnailUrl: row.thumbnail_url,
description: row.description,
subscriberCount: row.subscriber_count,
videoCount: row.video_count,
addedAt: row.added_at,
updatedAt: row.updated_at,
lastFetchedAt: row.last_fetched,
fetchError: row.fetch_error
}))
},
meta: {
total: result.rows.length
}
});
} catch (error: any) {
console.error('Get channels error:', error);
res.status(500).json({
success: false,
error: {
code: 'GET_CHANNELS_ERROR',
message: 'Error fetching channels'
}
});
}
}
export async function addChannel(req: AuthRequest, res: Response) {
try {
const { channelInput } = req.body;
// Fetch channel info from YouTube
const channelInfo = await fetchChannelInfo(channelInput);
// Check if channel already exists
const existing = await db.execute({
sql: 'SELECT id FROM channels WHERE id = ?',
args: [channelInfo.id]
});
if (existing.rows.length > 0) {
return res.status(409).json({
success: false,
error: {
code: 'CHANNEL_EXISTS',
message: 'Channel already exists in database'
}
});
}
// Insert channel
await db.execute({
sql: `INSERT INTO channels
(id, name, custom_url, thumbnail_url, description,
subscriber_count, video_count, uploads_playlist_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
channelInfo.id,
channelInfo.name,
channelInfo.customUrl,
channelInfo.thumbnailUrl,
channelInfo.description,
channelInfo.subscriberCount,
channelInfo.videoCount,
channelInfo.uploadsPlaylistId
]
});
// Fetch initial videos
let videosFetched = 0;
try {
const videos = await fetchChannelVideos(channelInfo.uploadsPlaylistId);
videosFetched = videos.length;
// Insert videos into cache
for (const video of videos) {
await db.execute({
sql: `INSERT INTO videos_cache
(id, channel_id, title, description, thumbnail_url,
published_at, view_count, like_count, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
video.id, channelInfo.id, video.title, video.description,
video.thumbnailUrl, video.publishedAt, video.viewCount,
video.likeCount, video.duration
]
});
}
// Update cache metadata
await db.execute({
sql: `INSERT INTO cache_metadata
(channel_id, last_fetched, total_results)
VALUES (?, ?, ?)`,
args: [channelInfo.id, new Date().toISOString(), videos.length]
});
} catch (error: any) {
console.error('Error fetching initial videos:', error);
// Store error but don't fail the channel addition
await db.execute({
sql: `INSERT INTO cache_metadata
(channel_id, last_fetched, fetch_error)
VALUES (?, ?, ?)`,
args: [channelInfo.id, new Date().toISOString(), error.message]
});
}
res.json({
success: true,
data: {
channel: channelInfo,
videosFetched
}
});
} catch (error: any) {
console.error('Add channel error:', error);
if (error.message.includes('not found')) {
return res.status(404).json({
success: false,
error: {
code: 'CHANNEL_NOT_FOUND',
message: error.message
}
});
}
if (error.message.includes('quota exceeded')) {
return res.status(503).json({
success: false,
error: {
code: 'QUOTA_EXCEEDED',
message: error.message,
retryable: true
}
});
}
res.status(500).json({
success: false,
error: {
code: 'ADD_CHANNEL_ERROR',
message: error.message || 'Error adding channel'
}
});
}
}
export async function deleteChannel(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const result = await db.execute({
sql: 'DELETE FROM channels WHERE id = ?',
args: [id]
});
if (result.rowsAffected === 0) {
return res.status(404).json({
success: false,
error: {
code: 'CHANNEL_NOT_FOUND',
message: 'Channel not found'
}
});
}
res.json({ success: true });
} catch (error: any) {
console.error('Delete channel error:', error);
res.status(500).json({
success: false,
error: {
code: 'DELETE_CHANNEL_ERROR',
message: 'Error deleting channel'
}
});
}
}
export async function refreshChannel(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
// Get channel info
const channel = await db.execute({
sql: 'SELECT * FROM channels WHERE id = ?',
args: [id]
});
if (!channel.rows.length) {
return res.status(404).json({
success: false,
error: {
code: 'CHANNEL_NOT_FOUND',
message: 'Channel not found'
}
});
}
const channelData = channel.rows[0];
// Fetch fresh videos
const videos = await fetchChannelVideos(channelData.uploads_playlist_id as string);
// Delete old cache
await db.execute({
sql: 'DELETE FROM videos_cache WHERE channel_id = ?',
args: [id]
});
// Insert new videos
for (const video of videos) {
await db.execute({
sql: `INSERT INTO videos_cache
(id, channel_id, title, description, thumbnail_url,
published_at, view_count, like_count, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
video.id, id, video.title, video.description,
video.thumbnailUrl, video.publishedAt, video.viewCount,
video.likeCount, video.duration
]
});
}
// Update metadata
await db.execute({
sql: `INSERT OR REPLACE INTO cache_metadata
(channel_id, last_fetched, total_results, fetch_error)
VALUES (?, ?, ?, NULL)`,
args: [id, new Date().toISOString(), videos.length]
});
res.json({
success: true,
data: {
channel: channelData,
videosFetched: videos.length
}
});
} catch (error: any) {
console.error('Refresh channel error:', error);
res.status(500).json({
success: false,
error: {
code: 'REFRESH_CHANNEL_ERROR',
message: error.message || 'Error refreshing channel'
}
});
}
}

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

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js';
import { formatDuration } from '../services/youtube.service.js';
import { refreshMultipleChannels } from '../services/cache.service.js';
export async function getAllVideos(req: AuthRequest, res: Response) {
try {
console.log('[CONTROLLER] req.query:', req.query);
// Zod validation already coerced these to numbers
const { page = 1, limit = 12, channelId, search, sort = 'newest' } = req.query as any;
console.log('[CONTROLLER] page:', page, 'type:', typeof page);
const pageNum = page as number;
const limitNum = limit as number;
const offset = (pageNum - 1) * limitNum;
console.log('[CONTROLLER] pageNum:', pageNum, 'limitNum:', limitNum, 'offset:', offset);
// Build query
let whereClause = '1=1';
const args: any[] = [];
if (channelId) {
whereClause += ' AND v.channel_id = ?';
args.push(channelId);
}
if (search) {
whereClause += ' AND (v.title LIKE ? OR v.description LIKE ?)';
args.push(`%${search}%`, `%${search}%`);
}
// Sort clause
let orderClause = 'v.published_at DESC';
if (sort === 'oldest') {
orderClause = 'v.published_at ASC';
} else if (sort === 'popular') {
orderClause = 'v.view_count DESC';
}
// Get total count
const countResult = await db.execute({
sql: `SELECT COUNT(*) as total FROM videos_cache v WHERE ${whereClause}`,
args
});
const total = countResult.rows[0].total as number;
// Get videos
const videosResult = await db.execute({
sql: `
SELECT
v.*,
c.name as channel_name,
c.thumbnail_url as channel_thumbnail
FROM videos_cache v
JOIN channels c ON v.channel_id = c.id
WHERE ${whereClause}
ORDER BY ${orderClause}
LIMIT ? OFFSET ?
`,
args: [...args, limitNum, offset]
});
// Get oldest cache age
const cacheAgeResult = await db.execute(
'SELECT MIN(last_fetched) as oldest FROM cache_metadata'
);
let oldestCacheAge = 0;
if (cacheAgeResult.rows.length > 0 && cacheAgeResult.rows[0].oldest) {
const oldestFetch = new Date(cacheAgeResult.rows[0].oldest as string);
oldestCacheAge = Math.floor((Date.now() - oldestFetch.getTime()) / 60000);
}
const videos = videosResult.rows.map(row => ({
id: row.id,
channelId: row.channel_id,
channelName: row.channel_name,
channelThumbnail: row.channel_thumbnail,
title: row.title,
description: row.description,
thumbnailUrl: row.thumbnail_url,
publishedAt: row.published_at,
viewCount: row.view_count,
likeCount: row.like_count,
duration: row.duration,
durationFormatted: formatDuration(row.duration as string)
}));
res.json({
success: true,
data: { videos },
meta: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum),
hasMore: offset + videos.length < total,
oldestCacheAge
}
});
} catch (error: any) {
console.error('Get videos error:', error);
res.status(500).json({
success: false,
error: {
code: 'GET_VIDEOS_ERROR',
message: 'Error fetching videos'
}
});
}
}
export async function refreshVideos(req: AuthRequest, res: Response) {
try {
let channelIds: string[] = req.body.channelIds || [];
// If no specific channels, get all channels
if (channelIds.length === 0) {
const allChannels = await db.execute('SELECT id FROM channels');
channelIds = allChannels.rows.map(row => row.id as string);
}
if (channelIds.length === 0) {
return res.json({
success: true,
data: {
channelsRefreshed: 0,
videosAdded: 0,
videosUpdated: 0,
errors: []
}
});
}
// Refresh channels in parallel
const result = await refreshMultipleChannels(channelIds, true);
res.json({
success: true,
data: {
channelsRefreshed: result.success,
videosAdded: result.videosAdded,
videosUpdated: result.videosAdded, // Since we replace cache, all are "updated"
errors: result.errors
}
});
} catch (error: any) {
console.error('Refresh videos error:', error);
res.status(500).json({
success: false,
error: {
code: 'REFRESH_VIDEOS_ERROR',
message: 'Error refreshing videos'
}
});
}
}

130
backend/src/db/migrate.ts

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
import { db } from '../config/database.js';
const migrations = [
{
id: 1,
name: 'initial_schema',
up: async () => {
// Create users table
await db.execute(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)
`);
await db.execute('CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)');
// Create channels table
await db.execute(`
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
custom_url TEXT,
thumbnail_url TEXT,
description TEXT,
subscriber_count INTEGER,
video_count INTEGER,
uploads_playlist_id TEXT NOT NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await db.execute('CREATE INDEX IF NOT EXISTS idx_channels_added_at ON channels(added_at DESC)');
// Create videos_cache table
await db.execute(`
CREATE TABLE IF NOT EXISTS videos_cache (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
thumbnail_url TEXT,
published_at DATETIME NOT NULL,
view_count INTEGER,
like_count INTEGER,
duration TEXT,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
)
`);
await db.execute('CREATE INDEX IF NOT EXISTS idx_videos_channel_id ON videos_cache(channel_id)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_videos_published_at ON videos_cache(published_at DESC)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_videos_cached_at ON videos_cache(cached_at)');
// Create cache_metadata table
await db.execute(`
CREATE TABLE IF NOT EXISTS cache_metadata (
channel_id TEXT PRIMARY KEY,
last_fetched DATETIME NOT NULL,
next_page_token TEXT,
total_results INTEGER,
fetch_error TEXT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
)
`);
// Create refresh_tokens table
await db.execute(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await db.execute('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)');
// Create settings table
await db.execute(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Insert default settings
await db.execute(`INSERT OR IGNORE INTO settings (key, value) VALUES ('cache_duration_minutes', '60')`);
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')`);
}
}
];
export async function runMigrations() {
// Create migrations tracking table
await db.execute(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Get executed migrations
const executed = await db.execute('SELECT id FROM migrations');
const executedIds = new Set(executed.rows.map(r => r.id));
// Run pending migrations
for (const migration of migrations) {
if (!executedIds.has(migration.id)) {
console.log(`Running migration: ${migration.name}...`);
await migration.up();
await db.execute({
sql: 'INSERT INTO migrations (id, name) VALUES (?, ?)',
args: [migration.id, migration.name]
});
console.log(`✓ Migration ${migration.name} completed`);
}
}
console.log('✓ All migrations completed');
}

65
backend/src/index.ts

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import { validateEnv, env } from './config/env.js';
import { runMigrations } from './db/migrate.js';
import { createInitialAdmin } from './setup/initialSetup.js';
import authRoutes from './routes/auth.routes.js';
import channelRoutes from './routes/channels.routes.js';
import videoRoutes from './routes/videos.routes.js';
import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js';
async function startServer() {
try {
console.log('🚀 Starting Kiddos Backend...\n');
// 1. Validate environment variables
validateEnv();
// 2. Run database migrations
await runMigrations();
// 3. Create initial admin if needed
await createInitialAdmin();
// 4. Set up Express app
const app = express();
// Middleware
app.use(cors({
origin: env.corsOrigin,
credentials: true
}));
app.use(express.json());
app.use(cookieParser());
app.use('/api', apiLimiter);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/channels', channelRoutes);
app.use('/api/videos', videoRoutes);
// Error handling
app.use(errorHandler);
// Start server
app.listen(env.port, () => {
console.log(`\n🚀 Server running on http://localhost:${env.port}`);
console.log(`📊 Environment: ${env.nodeEnv}`);
console.log(`🔒 CORS origin: ${env.corsOrigin}`);
console.log(`\n✨ Backend is ready!\n`);
});
} catch (error) {
console.error('❌ Failed to start server:', error);
process.exit(1);
}
}
startServer();

44
backend/src/middleware/auth.ts

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
import jwt from 'jsonwebtoken';
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../types/index.js';
import { env } from '../config/env.js';
export function authMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
) {
// Check for token in Authorization header or cookie
const token = req.cookies.auth_token ||
req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required'
}
});
}
try {
const decoded = jwt.verify(token, env.jwtSecret) as {
userId: number;
username: string;
};
req.userId = decoded.userId;
req.username = decoded.username;
next();
} catch (error) {
return res.status(401).json({
success: false,
error: {
code: 'INVALID_TOKEN',
message: 'Invalid or expired token'
}
});
}
}

24
backend/src/middleware/errorHandler.ts

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(
error: any,
req: Request,
res: Response,
next: NextFunction
) {
console.error('Error:', error);
// Default error response
const statusCode = error.statusCode || 500;
const message = error.message || 'Internal server error';
res.status(statusCode).json({
success: false,
error: {
code: error.code || 'INTERNAL_ERROR',
message: message,
details: process.env.NODE_ENV === 'development' ? error.stack : undefined
}
});
}

30
backend/src/middleware/rateLimiter.ts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
import rateLimit from 'express-rate-limit';
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: {
success: false,
error: {
code: 'RATE_LIMIT',
message: 'Too many login attempts. Please try again later.'
}
},
standardHeaders: true,
legacyHeaders: false
});
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 60, // 60 requests per minute
message: {
success: false,
error: {
code: 'RATE_LIMIT',
message: 'Too many requests. Please try again later.'
}
},
standardHeaders: true,
legacyHeaders: false
});

49
backend/src/middleware/validation.ts

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';
export const loginSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8)
});
export const addChannelSchema = z.object({
channelInput: z.string().min(1)
});
export const videoQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(50).default(12),
channelId: z.string().optional(),
search: z.string().optional(),
sort: z.enum(['newest', 'oldest', 'popular']).default('newest')
});
export function validateRequest(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
try {
console.log('[VALIDATION] Before validation:', req.query);
const validated = schema.parse(req.method === 'GET' ? req.query : req.body);
console.log('[VALIDATION] After validation:', validated);
if (req.method === 'GET') {
req.query = validated as any;
console.log('[VALIDATION] Set req.query to:', req.query);
} else {
req.body = validated;
}
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data',
details: error.errors
}
});
}
next(error);
}
};
}

15
backend/src/routes/auth.routes.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { Router } from 'express';
import { login, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, loginSchema } from '../middleware/validation.js';
import { loginLimiter } from '../middleware/rateLimiter.js';
const router = Router();
router.post('/login', loginLimiter, validateRequest(loginSchema), login);
router.post('/refresh', refresh);
router.post('/logout', authMiddleware, logout);
router.get('/me', authMiddleware, getCurrentUser);
export default router;

17
backend/src/routes/channels.routes.ts

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
import { Router } from 'express';
import { getAllChannels, addChannel, deleteChannel, refreshChannel } from '../controllers/channels.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, addChannelSchema } from '../middleware/validation.js';
const router = Router();
// Public route
router.get('/', getAllChannels);
// Protected routes
router.post('/', authMiddleware, validateRequest(addChannelSchema), addChannel);
router.delete('/:id', authMiddleware, deleteChannel);
router.put('/:id/refresh', authMiddleware, refreshChannel);
export default router;

15
backend/src/routes/videos.routes.ts

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
import { Router } from 'express';
import { getAllVideos, refreshVideos } from '../controllers/videos.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, videoQuerySchema } from '../middleware/validation.js';
const router = Router();
// Public route
router.get('/', validateRequest(videoQuerySchema), getAllVideos);
// Protected route
router.post('/refresh', authMiddleware, refreshVideos);
export default router;

92
backend/src/services/auth.service.ts

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { env } from '../config/env.js';
import { db } from '../config/database.js';
export async function createTokens(userId: number, username: string) {
// Access token (short-lived)
const accessToken = jwt.sign(
{ userId, username, type: 'access' },
env.jwtSecret,
{ expiresIn: env.accessTokenExpiry }
);
// Refresh token (long-lived)
const refreshTokenValue = crypto.randomBytes(64).toString('hex');
const refreshToken = jwt.sign(
{ token: refreshTokenValue, userId, type: 'refresh' },
env.jwtRefreshSecret,
{ expiresIn: env.refreshTokenExpiry }
);
// Store refresh token in database
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days
await db.execute({
sql: `INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES (?, ?, ?)`,
args: [userId, refreshTokenValue, expiresAt.toISOString()]
});
return { accessToken, refreshToken };
}
export async function refreshAccessToken(refreshToken: string) {
try {
const decoded = jwt.verify(refreshToken, env.jwtRefreshSecret) as {
token: string;
userId: number;
};
// Check if refresh token exists and is valid
const result = await db.execute({
sql: `SELECT rt.*, u.username
FROM refresh_tokens rt
JOIN users u ON rt.user_id = u.id
WHERE rt.token = ? AND rt.expires_at > ?`,
args: [decoded.token, new Date().toISOString()]
});
if (!result.rows.length) {
throw new Error('Invalid refresh token');
}
const tokenData = result.rows[0];
// Generate new access token
const accessToken = jwt.sign(
{ userId: tokenData.user_id, username: tokenData.username, type: 'access' },
env.jwtSecret,
{ expiresIn: env.accessTokenExpiry }
);
return { accessToken };
} catch (error) {
throw new Error('Invalid or expired refresh token');
}
}
export async function revokeRefreshToken(token: string) {
await db.execute({
sql: 'DELETE FROM refresh_tokens WHERE token = ?',
args: [token]
});
}
export async function revokeAllUserTokens(userId: number) {
await db.execute({
sql: 'DELETE FROM refresh_tokens WHERE user_id = ?',
args: [userId]
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}

140
backend/src/services/cache.service.ts

@ -0,0 +1,140 @@ @@ -0,0 +1,140 @@
import { db, getSetting } from '../config/database.js';
import { fetchChannelVideos } from './youtube.service.js';
export async function isCacheValid(channelId: string): Promise<boolean> {
const result = await db.execute({
sql: `SELECT last_fetched FROM cache_metadata WHERE channel_id = ?`,
args: [channelId]
});
if (!result.rows.length) return false;
const lastFetched = new Date(result.rows[0].last_fetched as string);
const cacheDuration = parseInt(
(await getSetting('cache_duration_minutes')) || '60'
);
const now = new Date();
const diffMinutes = (now.getTime() - lastFetched.getTime()) / 60000;
return diffMinutes < cacheDuration;
}
async function updateVideoCache(channelId: string, videos: any[]) {
// Delete old cache
await db.execute({
sql: 'DELETE FROM videos_cache WHERE channel_id = ?',
args: [channelId]
});
// Insert new videos
for (const video of videos) {
await db.execute({
sql: `INSERT INTO videos_cache
(id, channel_id, title, description, thumbnail_url,
published_at, view_count, like_count, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
video.id, channelId, video.title, video.description,
video.thumbnailUrl, video.publishedAt, video.viewCount,
video.likeCount, video.duration
]
});
}
// Update metadata
await db.execute({
sql: `INSERT OR REPLACE INTO cache_metadata
(channel_id, last_fetched, total_results)
VALUES (?, ?, ?)`,
args: [channelId, new Date().toISOString(), videos.length]
});
}
export async function getVideosForChannel(
channelId: string,
forceRefresh: boolean = false
): Promise<any[]> {
const channel = await db.execute({
sql: 'SELECT * FROM channels WHERE id = ?',
args: [channelId]
});
if (!channel.rows.length) {
throw new Error('Channel not found');
}
const cacheValid = !forceRefresh && await isCacheValid(channelId);
if (cacheValid) {
const cached = await db.execute({
sql: `SELECT * FROM videos_cache
WHERE channel_id = ?
ORDER BY published_at DESC`,
args: [channelId]
});
return cached.rows as any[];
}
// Fetch fresh data
try {
const channelData = channel.rows[0];
const videos = await fetchChannelVideos(
channelData.uploads_playlist_id as string
);
await updateVideoCache(channelId, videos);
// Clear any previous error
await db.execute({
sql: 'UPDATE cache_metadata SET fetch_error = NULL WHERE channel_id = ?',
args: [channelId]
});
return videos;
} catch (error: any) {
// Store error in cache_metadata
await db.execute({
sql: `INSERT OR REPLACE INTO cache_metadata
(channel_id, last_fetched, fetch_error)
VALUES (?, ?, ?)`,
args: [channelId, new Date().toISOString(), error.message]
});
throw error;
}
}
export async function refreshMultipleChannels(
channelIds: string[],
forceRefresh: boolean = false
): Promise<{
success: number;
failed: number;
errors: Array<{ channelId: string; error: string }>;
videosAdded: number;
}> {
const results = await Promise.allSettled(
channelIds.map(id => getVideosForChannel(id, forceRefresh))
);
let success = 0;
let failed = 0;
let videosAdded = 0;
const errors: Array<{ channelId: string; error: string }> = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
success++;
videosAdded += result.value.length;
} else {
failed++;
errors.push({
channelId: channelIds[index],
error: result.reason.message || 'Unknown error'
});
}
});
return { success, failed, errors, videosAdded };
}

148
backend/src/services/youtube.service.ts

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
import axios from 'axios';
import { env } from '../config/env.js';
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3';
export function parseChannelInput(input: string): { type: 'id' | 'handle' | 'username', value: string } {
input = input.trim();
// Direct channel ID (UC...)
if (/^UC[\w-]{21}[AQgw]$/.test(input)) {
return { type: 'id', value: input };
}
// @handle format
if (input.startsWith('@')) {
return { type: 'handle', value: input.substring(1) };
}
// Full YouTube URLs
const urlPatterns = [
{ regex: /youtube\.com\/channel\/(UC[\w-]{21}[AQgw])/, type: 'id' as const },
{ regex: /youtube\.com\/@([\w-]+)/, type: 'handle' as const },
{ regex: /youtube\.com\/c\/([\w-]+)/, type: 'username' as const },
{ regex: /youtube\.com\/user\/([\w-]+)/, type: 'username' as const }
];
for (const pattern of urlPatterns) {
const match = input.match(pattern.regex);
if (match) {
return { type: pattern.type, value: match[1] };
}
}
throw new Error('Invalid YouTube channel format. Use channel ID, @handle, or valid YouTube URL');
}
export async function fetchChannelInfo(input: string) {
const parsed = parseChannelInput(input);
let params: any = {
part: 'snippet,statistics,contentDetails',
key: env.youtubeApiKey
};
// Use appropriate parameter based on input type
if (parsed.type === 'id') {
params.id = parsed.value;
} else if (parsed.type === 'handle') {
params.forHandle = parsed.value;
} else {
params.forUsername = parsed.value;
}
try {
const response = await axios.get(`${YOUTUBE_API_BASE}/channels`, { params });
if (!response.data.items?.length) {
throw new Error('Channel not found on YouTube');
}
const channel = response.data.items[0];
return {
id: channel.id,
name: channel.snippet.title,
customUrl: channel.snippet.customUrl || null,
thumbnailUrl: channel.snippet.thumbnails.high.url,
description: channel.snippet.description,
subscriberCount: parseInt(channel.statistics.subscriberCount || '0'),
videoCount: parseInt(channel.statistics.videoCount || '0'),
uploadsPlaylistId: channel.contentDetails.relatedPlaylists.uploads
};
} catch (error: any) {
if (error.response?.status === 403) {
throw new Error('YouTube API quota exceeded. Please try again later.');
}
if (error.response?.status === 400) {
throw new Error('Invalid channel identifier');
}
throw error;
}
}
export async function fetchChannelVideos(
uploadsPlaylistId: string,
maxResults: number = 50
) {
try {
// Step 1: Get video IDs from playlist
const playlistResponse = await axios.get(`${YOUTUBE_API_BASE}/playlistItems`, {
params: {
part: 'contentDetails',
playlistId: uploadsPlaylistId,
maxResults,
key: env.youtubeApiKey
}
});
if (!playlistResponse.data.items?.length) {
return [];
}
const videoIds = playlistResponse.data.items
.map((item: any) => item.contentDetails.videoId)
.join(',');
// Step 2: Get video details
const videosResponse = await axios.get(`${YOUTUBE_API_BASE}/videos`, {
params: {
part: 'snippet,statistics,contentDetails',
id: videoIds,
key: env.youtubeApiKey
}
});
return videosResponse.data.items.map((video: any) => ({
id: video.id,
title: video.snippet.title,
description: video.snippet.description,
thumbnailUrl: video.snippet.thumbnails.maxresdefault?.url ||
video.snippet.thumbnails.high.url,
publishedAt: video.snippet.publishedAt,
viewCount: parseInt(video.statistics.viewCount || '0'),
likeCount: parseInt(video.statistics.likeCount || '0'),
duration: video.contentDetails.duration
}));
} catch (error: any) {
if (error.response?.status === 403) {
throw new Error('YouTube API quota exceeded');
}
throw error;
}
}
// Helper to format ISO 8601 duration to readable format
export function formatDuration(isoDuration: string): string {
const match = isoDuration.match(/PT(\d+H)?(\d+M)?(\d+S)?/);
if (!match) return '0:00';
const hours = (match[1] || '').replace('H', '');
const minutes = (match[2] || '').replace('M', '');
const seconds = (match[3] || '0').replace('S', '');
if (hours) {
return `${hours}:${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}`;
}
return `${minutes || '0'}:${seconds.padStart(2, '0')}`;
}

29
backend/src/setup/initialSetup.ts

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
import bcrypt from 'bcrypt';
import { db } from '../config/database.js';
import { env } from '../config/env.js';
export async function createInitialAdmin() {
const users = await db.execute('SELECT COUNT(*) as count FROM users');
const count = users.rows[0].count as number;
if (count === 0) {
const username = env.initialAdminUsername || 'admin';
const password = env.initialAdminPassword;
if (!password) {
console.error('❌ FATAL: No users exist and INITIAL_ADMIN_PASSWORD not set');
console.error(' Please set INITIAL_ADMIN_PASSWORD environment variable');
process.exit(1);
}
const hash = await bcrypt.hash(password, 10);
await db.execute({
sql: 'INSERT INTO users (username, password_hash) VALUES (?, ?)',
args: [username, hash]
});
console.log(`✓ Initial admin user created: ${username}`);
console.log(' Please change the admin password after first login');
}
}

61
backend/src/types/index.ts

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
import { Request } from 'express';
export interface AuthRequest extends Request {
userId?: number;
username?: string;
}
export interface Channel {
id: string;
name: string;
customUrl: string | null;
thumbnailUrl: string;
description: string;
subscriberCount: number;
videoCount: number;
uploadsPlaylistId: string;
addedAt: string;
updatedAt: string;
}
export interface Video {
id: string;
channelId: string;
title: string;
description: string;
thumbnailUrl: string;
publishedAt: string;
viewCount: number;
likeCount: number;
duration: string;
cachedAt: string;
}
export interface User {
id: number;
username: string;
passwordHash: string;
createdAt: string;
lastLogin: string | null;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
retryable?: boolean;
};
meta?: {
page?: number;
limit?: number;
total?: number;
totalPages?: number;
hasMore?: boolean;
cacheAge?: number;
oldestCacheAge?: number;
};
}

19
backend/tsconfig.json

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

14
frontend/index.html

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kiddos - YouTube Channel Aggregator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2003
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

25
frontend/package.json

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
{
"name": "kiddos-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"axios": "^1.6.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

61
frontend/src/App.css

@ -0,0 +1,61 @@ @@ -0,0 +1,61 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fff;
color: #030303;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
flex: 1;
}
/* Error container styling */
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
text-align: center;
}
.error-container h1 {
font-size: 24px;
margin-bottom: 16px;
color: #d00;
}
.error-container p {
font-size: 14px;
color: #606060;
margin-bottom: 24px;
}
.error-container button {
padding: 10px 20px;
background-color: #065fd4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.error-container button:hover {
background-color: #0556c4;
}

40
frontend/src/App.tsx

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './hooks/useAuth';
import { ErrorBoundary } from './components/ErrorBoundary';
import { Navbar } from './components/Navbar/Navbar';
import { ProtectedRoute } from './components/ProtectedRoute';
import { HomePage } from './pages/HomePage';
import { AdminPage } from './pages/AdminPage';
import { LoginPage } from './pages/LoginPage';
import './App.css';
function App() {
return (
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<div className="app">
<Navbar />
<main className="main-content">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
</Routes>
</main>
</div>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
);
}
export default App;

174
frontend/src/components/ChannelManager/ChannelManager.css

@ -0,0 +1,174 @@ @@ -0,0 +1,174 @@
.channel-manager {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.channel-manager h2 {
margin: 0 0 24px 0;
font-size: 24px;
font-weight: 500;
}
.add-channel-form {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.channel-input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.channel-input:focus {
outline: none;
border-color: #065fd4;
}
.add-button {
padding: 12px 24px;
background-color: #065fd4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.2s;
}
.add-button:hover:not(:disabled) {
background-color: #0556c4;
}
.add-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert-success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
}
.empty-message {
text-align: center;
padding: 48px 24px;
color: #606060;
font-size: 14px;
}
.channels-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background-color: #f9f9f9;
border-radius: 8px;
border: 1px solid #e5e5e5;
}
.channel-thumbnail {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.channel-info {
flex: 1;
min-width: 0;
}
.channel-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 500;
color: #030303;
}
.channel-stats {
margin: 0 0 4px 0;
font-size: 14px;
color: #606060;
}
.channel-meta {
margin: 0;
font-size: 12px;
color: #909090;
}
.channel-error {
margin: 0;
font-size: 12px;
color: #d00;
}
.remove-button {
padding: 8px 16px;
background-color: #fff;
color: #d00;
border: 1px solid #d00;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.remove-button:hover {
background-color: #d00;
color: white;
}
@media (max-width: 768px) {
.channel-manager {
padding: 16px;
}
.add-channel-form {
flex-direction: column;
}
.channel-item {
flex-direction: column;
align-items: flex-start;
}
.channel-thumbnail {
width: 60px;
height: 60px;
}
.remove-button {
align-self: flex-end;
}
}

114
frontend/src/components/ChannelManager/ChannelManager.tsx

@ -0,0 +1,114 @@ @@ -0,0 +1,114 @@
import { useState } from 'react';
import { useChannels } from '../../hooks/useChannels';
import './ChannelManager.css';
export function ChannelManager() {
const { channels, loading, error, addChannel, removeChannel } = useChannels();
const [channelInput, setChannelInput] = useState('');
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [addSuccess, setAddSuccess] = useState<string | null>(null);
const handleAddChannel = async (e: React.FormEvent) => {
e.preventDefault();
if (!channelInput.trim()) return;
setAdding(true);
setAddError(null);
setAddSuccess(null);
try {
const result = await addChannel(channelInput.trim());
setAddSuccess(`Added ${result.channel.name} with ${result.videosFetched} videos`);
setChannelInput('');
} catch (err: any) {
setAddError(err.error?.message || 'Failed to add channel');
} finally {
setAdding(false);
}
};
const handleRemoveChannel = async (channelId: string, channelName: string) => {
if (!confirm(`Are you sure you want to remove ${channelName}?`)) return;
try {
await removeChannel(channelId);
} catch (err: any) {
alert('Failed to remove channel: ' + (err.error?.message || 'Unknown error'));
}
};
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
};
return (
<div className="channel-manager">
<h2>Channel Management</h2>
<form onSubmit={handleAddChannel} className="add-channel-form">
<input
type="text"
placeholder="Enter channel ID, @handle, or YouTube URL..."
value={channelInput}
onChange={(e) => setChannelInput(e.target.value)}
disabled={adding}
className="channel-input"
/>
<button type="submit" disabled={adding || !channelInput.trim()} className="add-button">
{adding ? 'Adding...' : 'Add Channel'}
</button>
</form>
{addError && <div className="alert alert-error">{addError}</div>}
{addSuccess && <div className="alert alert-success">{addSuccess}</div>}
{loading && <p>Loading channels...</p>}
{error && <div className="alert alert-error">{error}</div>}
{!loading && channels.length === 0 && (
<p className="empty-message">No channels added yet. Add your first channel above!</p>
)}
{channels.length > 0 && (
<div className="channels-list">
{channels.map(channel => (
<div key={channel.id} className="channel-item">
<img
src={channel.thumbnailUrl}
alt={channel.name}
className="channel-thumbnail"
/>
<div className="channel-info">
<h3 className="channel-name">{channel.name}</h3>
<p className="channel-stats">
{formatNumber(channel.subscriberCount)} subscribers {channel.videoCount} videos
</p>
{channel.lastFetchedAt && (
<p className="channel-meta">
Last updated: {new Date(channel.lastFetchedAt).toLocaleString()}
</p>
)}
{channel.fetchError && (
<p className="channel-error">Error: {channel.fetchError}</p>
)}
</div>
<button
onClick={() => handleRemoveChannel(channel.id, channel.name)}
className="remove-button"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
);
}

42
frontend/src/components/ErrorBoundary.tsx

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
import React from 'react';
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-container">
<h1>Something went wrong</h1>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}

103
frontend/src/components/Navbar/Navbar.css

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
.navbar {
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.navbar-container {
max-width: 1600px;
margin: 0 auto;
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #030303;
font-size: 20px;
font-weight: 600;
}
.logo-icon {
font-size: 24px;
}
.logo-text {
font-family: 'YouTube Sans', 'Roboto', sans-serif;
}
.navbar-menu {
display: flex;
align-items: center;
gap: 24px;
}
.navbar-link {
text-decoration: none;
color: #030303;
font-size: 14px;
font-weight: 500;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
}
.navbar-link:hover {
background-color: #f2f2f2;
}
.navbar-user {
display: flex;
align-items: center;
gap: 12px;
}
.navbar-username {
font-size: 14px;
color: #606060;
}
.navbar-button {
background-color: #065fd4;
color: white;
border: none;
padding: 8px 16px;
border-radius: 18px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background-color 0.2s;
}
.navbar-button:hover {
background-color: #0556c4;
}
@media (max-width: 768px) {
.navbar-container {
padding: 8px 16px;
}
.navbar-menu {
gap: 12px;
}
.logo-text {
display: none;
}
.navbar-username {
display: none;
}
}

47
frontend/src/components/Navbar/Navbar.tsx

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import './Navbar.css';
export function Navbar() {
const { isAuthenticated, user, logout } = useAuth();
const handleLogout = async () => {
await logout();
};
return (
<nav className="navbar">
<div className="navbar-container">
<Link to="/" className="navbar-logo">
<span className="logo-icon">📺</span>
</Link>
<div className="navbar-menu">
<Link to="/" className="navbar-link">
Home
</Link>
{isAuthenticated && (
<Link to="/admin" className="navbar-link">
Admin
</Link>
)}
{isAuthenticated ? (
<div className="navbar-user">
<span className="navbar-username">{user?.username}</span>
<button onClick={handleLogout} className="navbar-button">
Logout
</button>
</div>
) : (
<Link to="/login" className="navbar-button">
Login
</Link>
)}
</div>
</div>
</nav>
);
}

21
frontend/src/components/ProtectedRoute.tsx

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div style={{ padding: '48px', textAlign: 'center' }}>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

110
frontend/src/components/SearchFilter/SearchFilter.css

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
.search-filter {
background-color: #f9f9f9;
border-bottom: 1px solid #e5e5e5;
padding: 16px 24px;
}
.search-filter-container {
max-width: 1600px;
margin: 0 auto;
display: flex;
gap: 16px;
align-items: center;
}
.search-form {
flex: 1;
display: flex;
gap: 8px;
max-width: 500px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #065fd4;
}
.search-button {
padding: 8px 16px;
background-color: #f2f2f2;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.search-button:hover {
background-color: #e5e5e5;
}
.filter-controls {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
background-color: white;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: #065fd4;
}
.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;
}
.clear-button:hover {
background-color: #f2f2f2;
}
@media (max-width: 768px) {
.search-filter {
padding: 12px 16px;
}
.search-filter-container {
flex-direction: column;
align-items: stretch;
}
.search-form {
max-width: 100%;
}
.filter-controls {
flex-wrap: wrap;
}
.filter-select {
flex: 1;
}
.clear-button {
flex: 1;
}
}

82
frontend/src/components/SearchFilter/SearchFilter.tsx

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
import { useState } from 'react';
import './SearchFilter.css';
interface SearchFilterProps {
onSearch: (query: string) => void;
onSortChange: (sort: 'newest' | 'oldest' | 'popular') => void;
channels: Array<{ id: string; name: string }>;
selectedChannel: string | undefined;
onChannelChange: (channelId: string | undefined) => void;
}
export function SearchFilter({
onSearch,
onSortChange,
channels,
selectedChannel,
onChannelChange
}: SearchFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchQuery);
};
return (
<div className="search-filter">
<div className="search-filter-container">
<form onSubmit={handleSearchSubmit} className="search-form">
<input
type="text"
placeholder="Search videos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
<button type="submit" className="search-button">
🔍
</button>
</form>
<div className="filter-controls">
<select
onChange={(e) => onSortChange(e.target.value as any)}
className="filter-select"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="popular">Most Popular</option>
</select>
<select
value={selectedChannel || ''}
onChange={(e) => onChannelChange(e.target.value || undefined)}
className="filter-select"
>
<option value="">All Channels</option>
{channels.map(channel => (
<option key={channel.id} value={channel.id}>
{channel.name}
</option>
))}
</select>
{(searchQuery || selectedChannel) && (
<button
onClick={() => {
setSearchQuery('');
onSearch('');
onChannelChange(undefined);
}}
className="clear-button"
>
Clear Filters
</button>
)}
</div>
</div>
</div>
);
}

103
frontend/src/components/VideoCard/VideoCard.css

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
.video-card {
cursor: pointer;
transition: transform 0.2s;
}
.video-card:hover {
transform: translateY(-2px);
}
.video-thumbnail-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 12px;
background-color: #f0f0f0;
}
.video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.video-card:hover .video-thumbnail {
transform: scale(1.05);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.video-info {
display: flex;
gap: 12px;
margin-top: 12px;
}
.channel-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.video-details {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: #030303;
margin: 0 0 6px 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.video-metadata {
margin: 0;
font-size: 12px;
color: #606060;
display: flex;
flex-direction: column;
gap: 2px;
}
.channel-name {
font-weight: 400;
}
.video-stats {
font-weight: 400;
}
@media (max-width: 768px) {
.channel-avatar {
width: 32px;
height: 32px;
}
.video-title {
font-size: 13px;
}
.video-metadata {
font-size: 11px;
}
}

63
frontend/src/components/VideoCard/VideoCard.tsx

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
import { Video } from '../../types/api';
import './VideoCard.css';
interface VideoCardProps {
video: Video;
onClick: () => void;
}
export function VideoCard({ video, onClick }: VideoCardProps) {
const formatViews = (count: number): string => {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
} else if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toString();
};
const getTimeAgo = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
};
return (
<div className="video-card" onClick={onClick}>
<div className="video-thumbnail-container">
<img
src={video.thumbnailUrl}
alt={video.title}
className="video-thumbnail"
/>
<span className="video-duration">{video.durationFormatted}</span>
</div>
<div className="video-info">
<img
src={video.channelThumbnail}
alt={video.channelName}
className="channel-avatar"
/>
<div className="video-details">
<h3 className="video-title">{video.title}</h3>
<p className="video-metadata">
<span className="channel-name">{video.channelName}</span>
<span className="video-stats">
{formatViews(video.viewCount)} views {getTimeAgo(video.publishedAt)}
</span>
</p>
</div>
</div>
</div>
);
}

155
frontend/src/components/VideoGrid/VideoGrid.css

@ -0,0 +1,155 @@ @@ -0,0 +1,155 @@
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
.skeleton-card {
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-thumbnail {
width: 100%;
aspect-ratio: 16 / 9;
background-color: #e5e5e5;
border-radius: 12px;
}
.skeleton-info {
display: flex;
gap: 12px;
margin-top: 12px;
}
.skeleton-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #e5e5e5;
}
.skeleton-text {
flex: 1;
}
.skeleton-title {
height: 16px;
background-color: #e5e5e5;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-meta {
height: 12px;
width: 60%;
background-color: #e5e5e5;
border-radius: 4px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.error-message {
text-align: center;
padding: 48px 24px;
color: #d00;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: #606060;
}
.empty-state h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 500;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 24px;
margin: 0 auto;
max-width: 1600px;
}
.pagination-button {
padding: 8px 16px;
background-color: #f2f2f2;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.pagination-button:hover:not(:disabled) {
background-color: #e5e5e5;
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-numbers {
display: flex;
gap: 4px;
}
.pagination-number {
width: 36px;
height: 36px;
background-color: #f2f2f2;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.pagination-number:hover {
background-color: #e5e5e5;
}
.pagination-number.active {
background-color: #065fd4;
color: white;
}
@media (max-width: 768px) {
.video-grid {
grid-template-columns: 1fr;
gap: 16px;
padding: 16px;
}
.pagination {
padding: 16px;
}
.pagination-numbers {
display: none;
}
}

119
frontend/src/components/VideoGrid/VideoGrid.tsx

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
import { Video } from '../../types/api';
import { VideoCard } from '../VideoCard/VideoCard';
import './VideoGrid.css';
interface VideoGridProps {
videos: Video[];
loading: boolean;
error: string | null;
onVideoClick: (videoId: string) => void;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function VideoGrid({
videos,
loading,
error,
onVideoClick,
page,
totalPages,
onPageChange
}: VideoGridProps) {
if (loading) {
return (
<div className="video-grid">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-thumbnail"></div>
<div className="skeleton-info">
<div className="skeleton-avatar"></div>
<div className="skeleton-text">
<div className="skeleton-title"></div>
<div className="skeleton-meta"></div>
</div>
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="error-message">
<p>Error: {error}</p>
</div>
);
}
if (videos.length === 0) {
return (
<div className="empty-state">
<h2>No videos found</h2>
<p>Try adding some channels from the admin panel</p>
</div>
);
}
return (
<div>
<div className="video-grid">
{videos.map(video => (
<VideoCard
key={video.id}
video={video}
onClick={() => onVideoClick(video.id)}
/>
))}
</div>
{totalPages > 1 && (
<div className="pagination">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
className="pagination-button"
>
Previous
</button>
<div className="pagination-numbers">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => onPageChange(pageNum)}
className={`pagination-number ${page === pageNum ? 'active' : ''}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
className="pagination-button"
>
Next
</button>
</div>
)}
</div>
);
}

81
frontend/src/components/VideoPlayer/VideoPlayer.css

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
position: relative;
width: 100%;
max-width: 1200px;
background-color: #000;
border-radius: 8px;
overflow: hidden;
}
.close-button {
position: absolute;
top: -40px;
right: 0;
background: none;
border: none;
color: white;
font-size: 40px;
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.close-button:hover {
opacity: 0.7;
}
.video-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
@media (max-width: 768px) {
.modal-overlay {
padding: 0;
}
.modal-content {
max-width: 100%;
border-radius: 0;
}
.close-button {
top: 10px;
right: 10px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 32px;
}
}

44
frontend/src/components/VideoPlayer/VideoPlayer.tsx

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
import { useEffect } from 'react';
import './VideoPlayer.css';
interface VideoPlayerProps {
videoId: string;
onClose: () => void;
}
export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
useEffect(() => {
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Handle Escape key
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="close-button" onClick={onClose}>×</button>
<div className="video-container">
<iframe
width="100%"
height="100%"
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video player"
/>
</div>
</div>
</div>
);
}

71
frontend/src/hooks/useAuth.tsx

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
import { useState, useEffect, createContext, useContext, ReactNode } from 'react';
import { authApi } from '../services/apiClient';
import { User } from '../types/api';
interface AuthContextType {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in on mount
const token = localStorage.getItem('access_token');
if (token) {
authApi.getCurrentUser()
.then((response: any) => setUser(response.data))
.catch(() => {
setUser(null);
localStorage.removeItem('access_token');
})
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (username: string, password: string) => {
const response: any = await authApi.login(username, password);
setUser(response.data.user);
localStorage.setItem('access_token', response.data.accessToken);
};
const logout = async () => {
try {
await authApi.logout();
} catch (error) {
console.error('Logout error:', error);
}
setUser(null);
localStorage.removeItem('access_token');
};
return (
<AuthContext.Provider value={{
user,
loading,
login,
logout,
isAuthenticated: !!user
}}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};

55
frontend/src/hooks/useChannels.ts

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
import { useState, useEffect } from 'react';
import { channelsApi } from '../services/apiClient';
import { Channel } from '../types/api';
export function useChannels() {
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchChannels = async () => {
setLoading(true);
setError(null);
try {
const response: any = await channelsApi.getAll();
setChannels(response.data.channels);
} catch (err: any) {
setError(err.error?.message || 'Failed to fetch channels');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchChannels();
}, []);
const addChannel = async (channelInput: string) => {
const response: any = await channelsApi.add(channelInput);
await fetchChannels(); // Refresh list
return response.data;
};
const removeChannel = async (channelId: string) => {
await channelsApi.remove(channelId);
await fetchChannels(); // Refresh list
};
const refreshChannel = async (channelId: string) => {
const response: any = await channelsApi.refresh(channelId);
await fetchChannels(); // Refresh list
return response.data;
};
return {
channels,
loading,
error,
addChannel,
removeChannel,
refreshChannel,
refetch: fetchChannels
};
}

49
frontend/src/hooks/useVideos.ts

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { videosApi } from '../services/apiClient';
import { Video } from '../types/api';
interface UseVideosParams {
page?: number;
limit?: number;
channelId?: string;
search?: string;
sort?: 'newest' | 'oldest' | 'popular';
}
export function useVideos(params: UseVideosParams = {}) {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState({
page: 1,
limit: 12,
total: 0,
totalPages: 0,
hasMore: false,
oldestCacheAge: 0
});
const { page, limit, channelId, search, sort } = params;
useEffect(() => {
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
const response: any = await videosApi.getAll(params);
setVideos(response.data.videos);
setMeta(response.meta);
} catch (err: any) {
setError(err.error?.message || 'Failed to fetch videos');
} finally {
setLoading(false);
}
};
fetchVideos();
}, [page, limit, channelId, search, sort]);
return { videos, loading, error, meta };
}

10
frontend/src/main.tsx

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

25
frontend/src/pages/AdminPage.css

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
.admin-page {
min-height: calc(100vh - 60px);
background-color: #f9f9f9;
}
.admin-header {
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
padding: 32px 24px;
text-align: center;
}
.admin-header h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 500;
color: #030303;
}
.admin-header p {
margin: 0;
font-size: 14px;
color: #606060;
}

16
frontend/src/pages/AdminPage.tsx

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
import { ChannelManager } from '../components/ChannelManager/ChannelManager';
import './AdminPage.css';
export function AdminPage() {
return (
<div className="admin-page">
<div className="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage YouTube channels to display on the home page</p>
</div>
<ChannelManager />
</div>
);
}

74
frontend/src/pages/HomePage.tsx

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
import { useState } from 'react';
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<string | undefined>();
const [selectedVideo, setSelectedVideo] = useState<string | null>(null);
const { videos, loading, error, meta } = useVideos({
page,
limit: 12,
search: search || undefined,
sort,
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);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div>
<SearchFilter
onSearch={handleSearch}
onSortChange={handleSortChange}
channels={channels}
selectedChannel={selectedChannel}
onChannelChange={handleChannelChange}
/>
<VideoGrid
videos={videos}
loading={loading}
error={error}
onVideoClick={setSelectedVideo}
page={page}
totalPages={meta.totalPages}
onPageChange={handlePageChange}
/>
{selectedVideo && (
<VideoPlayer
videoId={selectedVideo}
onClose={() => setSelectedVideo(null)}
/>
)}
</div>
);
}

99
frontend/src/pages/LoginPage.css

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
.login-page {
min-height: calc(100vh - 60px);
display: flex;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
padding: 24px;
}
.login-container {
width: 100%;
max-width: 400px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.login-header {
padding: 32px 32px 24px;
text-align: center;
border-bottom: 1px solid #e5e5e5;
}
.login-header h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 500;
color: #030303;
}
.login-header p {
margin: 0;
font-size: 14px;
color: #606060;
}
.login-form {
padding: 32px;
}
.login-error {
padding: 12px;
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
border-radius: 4px;
font-size: 14px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #030303;
}
.form-group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #065fd4;
}
.login-button {
width: 100%;
padding: 12px;
background-color: #065fd4;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.login-button:hover:not(:disabled) {
background-color: #0556c4;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}

74
frontend/src/pages/LoginPage.tsx

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import './LoginPage.css';
export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(username, password);
navigate('/admin');
} catch (err: any) {
setError(err.error?.message || 'Invalid username or password');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-container">
<div className="login-header">
<h1>Admin Login</h1>
<p>Sign in to manage channels</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
{error && <div className="login-error">{error}</div>}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
/>
</div>
<button type="submit" disabled={loading} className="login-button">
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}

121
frontend/src/services/apiClient.ts

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
withCredentials: true,
headers: { 'Content-Type': 'application/json' }
});
let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// Request interceptor: attach access token
api.interceptors.request.use(
config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
// Response interceptor: handle token refresh
api.interceptors.response.use(
response => response.data,
async error => {
const originalRequest = error.config;
// If 401 and not already retrying
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Try to refresh token
const response = await axios.post(
`${api.defaults.baseURL}/auth/refresh`,
{},
{ withCredentials: true }
);
const { accessToken } = response.data.data;
localStorage.setItem('access_token', accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
processQueue(null, accessToken);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('access_token');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error.response?.data || error);
}
);
// Auth API
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
logout: () => api.post('/auth/logout'),
getCurrentUser: () => api.get('/auth/me'),
refresh: () => api.post('/auth/refresh')
};
// Channels API
export const channelsApi = {
getAll: () => api.get('/channels'),
add: (channelInput: string) =>
api.post('/channels', { channelInput }),
remove: (channelId: string) =>
api.delete(`/channels/${channelId}`),
refresh: (channelId: string) =>
api.put(`/channels/${channelId}/refresh`)
};
// Videos API
export const videosApi = {
getAll: (params?: any) => api.get('/videos', { params }),
search: (query: string, params?: any) =>
api.get('/videos/search', { params: { q: query, ...params } }),
refresh: (channelIds?: string[]) =>
api.post('/videos/refresh', { channelIds })
};

54
frontend/src/types/api.ts

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
export interface Channel {
id: string;
name: string;
customUrl: string | null;
thumbnailUrl: string;
description: string;
subscriberCount: number;
videoCount: number;
addedAt: string;
updatedAt: string;
lastFetchedAt?: string;
fetchError?: string;
}
export interface Video {
id: string;
channelId: string;
channelName: string;
channelThumbnail: string;
title: string;
description: string;
thumbnailUrl: string;
publishedAt: string;
viewCount: number;
likeCount: number;
duration: string;
durationFormatted: string;
}
export interface User {
id: number;
username: string;
lastLogin?: string;
}
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
retryable?: boolean;
};
meta?: {
page?: number;
limit?: number;
total?: number;
totalPages?: number;
hasMore?: boolean;
oldestCacheAge?: number;
};
}

22
frontend/tsconfig.json

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
frontend/tsconfig.node.json

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})

372
package-lock.json generated

@ -0,0 +1,372 @@ @@ -0,0 +1,372 @@
{
"name": "kiddos",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kiddos",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

17
package.json

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
{
"name": "kiddos",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"build": "npm run build:backend && npm run build:frontend",
"build:backend": "cd backend && npm run build",
"build:frontend": "cd frontend && npm run build"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

148
pagination-debug-plan.md

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
# 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