commit
0c64390062
61 changed files with 9709 additions and 0 deletions
@ -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 @@ |
|||||||
|
# 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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
<!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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
* { |
||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
.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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"composite": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowSyntheticDefaultImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
||||||
|
|
||||||
@ -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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
# 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