commit
0c64390062
61 changed files with 9709 additions and 0 deletions
@ -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/ |
||||
|
||||
@ -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 |
||||
File diff suppressed because it is too large
Load Diff
@ -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" |
||||
} |
||||
} |
||||
|
||||
@ -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()] |
||||
}); |
||||
} |
||||
|
||||
@ -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 |
||||
}; |
||||
|
||||
@ -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' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
@ -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' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
@ -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' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
@ -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'); |
||||
} |
||||
|
||||
@ -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(); |
||||
|
||||
@ -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' |
||||
} |
||||
}); |
||||
} |
||||
} |
||||
|
||||
@ -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 |
||||
} |
||||
}); |
||||
} |
||||
|
||||
@ -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 |
||||
}); |
||||
|
||||
@ -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); |
||||
} |
||||
}; |
||||
} |
||||
|
||||
@ -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; |
||||
|
||||
@ -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; |
||||
|
||||
@ -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; |
||||
|
||||
@ -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); |
||||
} |
||||
|
||||
@ -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 }; |
||||
} |
||||
|
||||
@ -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')}`; |
||||
} |
||||
|
||||
@ -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'); |
||||
} |
||||
} |
||||
|
||||
@ -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; |
||||
}; |
||||
} |
||||
|
||||
@ -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"] |
||||
} |
||||
|
||||
@ -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> |
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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" |
||||
} |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
|
||||
@ -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; |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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}</>; |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
}; |
||||
|
||||
@ -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 |
||||
}; |
||||
} |
||||
|
||||
@ -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 }; |
||||
} |
||||
|
||||
@ -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>, |
||||
) |
||||
|
||||
@ -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; |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
|
||||
@ -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> |
||||
); |
||||
} |
||||
|
||||
@ -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 }) |
||||
}; |
||||
|
||||
@ -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; |
||||
}; |
||||
} |
||||
|
||||
@ -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" }] |
||||
} |
||||
|
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
{ |
||||
"compilerOptions": { |
||||
"composite": true, |
||||
"skipLibCheck": true, |
||||
"module": "ESNext", |
||||
"moduleResolution": "bundler", |
||||
"allowSyntheticDefaultImports": true |
||||
}, |
||||
"include": ["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 |
||||
} |
||||
}) |
||||
|
||||
@ -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" |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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" |
||||
} |
||||
} |
||||
|
||||
@ -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…
Reference in new issue