diff --git a/backend/src/controllers/settings.controller.ts b/backend/src/controllers/settings.controller.ts new file mode 100644 index 0000000..dfff1d2 --- /dev/null +++ b/backend/src/controllers/settings.controller.ts @@ -0,0 +1,60 @@ +import { Response } from 'express'; +import { AuthRequest } from '../types/index.js'; +import { getSetting, setSetting } from '../config/database.js'; + +export async function getTimeLimit(req: AuthRequest, res: Response) { + try { + const limit = await getSetting('daily_time_limit_minutes'); + const defaultLimit = 1; // Default 1 minute for testing + + res.json({ + success: true, + data: { + dailyLimit: limit ? parseInt(limit, 10) : defaultLimit + } + }); + } catch (error: any) { + console.error('Get time limit error:', error); + res.status(500).json({ + success: false, + error: { + code: 'GET_TIME_LIMIT_ERROR', + message: 'Error fetching time limit' + } + }); + } +} + +export async function setTimeLimit(req: AuthRequest, res: Response) { + try { + const { dailyLimit } = req.body; + + if (!dailyLimit || typeof dailyLimit !== 'number' || dailyLimit < 1) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_LIMIT', + message: 'Daily limit must be a number greater than 0' + } + }); + } + + await setSetting('daily_time_limit_minutes', dailyLimit.toString()); + + res.json({ + success: true, + data: { + dailyLimit + } + }); + } catch (error: any) { + console.error('Set time limit error:', error); + res.status(500).json({ + success: false, + error: { + code: 'SET_TIME_LIMIT_ERROR', + message: 'Error setting time limit' + } + }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index fe1ba49..b0bac0a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7,6 +7,7 @@ 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 settingsRoutes from './routes/settings.routes.js'; import { errorHandler } from './middleware/errorHandler.js'; import { apiLimiter } from './middleware/rateLimiter.js'; @@ -44,6 +45,7 @@ async function startServer() { app.use('/api/auth', authRoutes); app.use('/api/channels', channelRoutes); app.use('/api/videos', videoRoutes); + app.use('/api/settings', settingsRoutes); // Error handling app.use(errorHandler); diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..24fcdd3 --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { getTimeLimit, setTimeLimit } from '../controllers/settings.controller.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = Router(); + +// Protected routes - only admins can get/set time limits +router.get('/time-limit', authMiddleware, getTimeLimit); +router.put('/time-limit', authMiddleware, setTimeLimit); + +export default router; diff --git a/frontend/src/components/ChannelManager/ChannelManager.css b/frontend/src/components/ChannelManager/ChannelManager.css index 1ee7c03..72e7dcb 100644 --- a/frontend/src/components/ChannelManager/ChannelManager.css +++ b/frontend/src/components/ChannelManager/ChannelManager.css @@ -1,7 +1,9 @@ .channel-manager { - max-width: 1000px; - margin: 0 auto; + width: 100%; padding: 24px; + background: var(--color-surface); + border-radius: 12px; + border: 1px solid rgba(212, 222, 239, 0.8); } .channel-manager h2 { diff --git a/frontend/src/components/TimeLimitManager/TimeLimitManager.css b/frontend/src/components/TimeLimitManager/TimeLimitManager.css new file mode 100644 index 0000000..35769f8 --- /dev/null +++ b/frontend/src/components/TimeLimitManager/TimeLimitManager.css @@ -0,0 +1,268 @@ +.time-limit-manager { + background: var(--color-surface); + border-radius: 12px; + padding: 24px; + border: 1px solid rgba(212, 222, 239, 0.8); + height: fit-content; +} + +.time-limit-header { + margin-bottom: 24px; +} + +.time-limit-header h2 { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 600; + color: var(--color-text); +} + +.time-limit-header p { + margin: 0; + font-size: 14px; + color: var(--color-muted); +} + +.time-limit-section { + display: flex; + flex-direction: column; + gap: 24px; +} + +.time-limit-setting { + display: flex; + flex-direction: column; + gap: 12px; +} + +.time-limit-setting label { + font-size: 14px; + font-weight: 500; + color: var(--color-text); +} + +.time-limit-input-group { + display: flex; + gap: 12px; + align-items: center; +} + +.time-limit-input { + flex: 1; + max-width: 200px; + padding: 10px 12px; + border: 1px solid rgba(212, 222, 239, 0.8); + border-radius: 6px; + font-size: 14px; + background: var(--color-surface-muted); + color: var(--color-text); + transition: border-color 0.2s; +} + +.time-limit-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.time-limit-save-btn { + padding: 10px 20px; + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} + +.time-limit-save-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3); +} + +.time-limit-save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.time-limit-hint { + margin: 0; + font-size: 13px; + color: var(--color-muted); +} + +.time-limit-status { + padding: 20px; + background: var(--color-surface-muted); + border-radius: 8px; + border: 1px solid rgba(212, 222, 239, 0.5); +} + +.time-limit-status h3 { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text); +} + +.time-limit-progress { + display: flex; + flex-direction: column; + gap: 12px; +} + +.time-limit-progress-bar { + width: 100%; + height: 24px; + background: rgba(212, 222, 239, 0.3); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.time-limit-progress-fill { + height: 100%; + background: linear-gradient(90deg, #4a90e2, #357abd); + transition: width 0.3s ease; + border-radius: 12px; +} + +.time-limit-stats { + display: flex; + justify-content: space-between; + font-size: 14px; + color: var(--color-text); +} + +.time-used strong { + color: var(--color-primary); +} + +.time-remaining strong { + color: #4a90e2; +} + +.time-limit-actions { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(212, 222, 239, 0.5); +} + +.time-limit-reset-btn { + padding: 8px 16px; + background: transparent; + color: var(--color-primary); + border: 1px solid var(--color-primary); + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.time-limit-reset-btn:hover { + background: var(--color-primary); + color: white; +} + +.time-limit-confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.time-limit-confirm-modal { + background: var(--color-surface); + border-radius: 12px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); +} + +.time-limit-confirm-modal h3 { + margin: 0 0 12px 0; + font-size: 18px; + font-weight: 600; + color: var(--color-text); +} + +.time-limit-confirm-modal p { + margin: 0 0 20px 0; + font-size: 14px; + color: var(--color-muted); + line-height: 1.5; +} + +.time-limit-confirm-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.time-limit-confirm-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} + +.time-limit-confirm-btn.confirm { + background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); + color: white; +} + +.time-limit-confirm-btn.confirm:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(47, 128, 237, 0.3); +} + +.time-limit-confirm-btn.cancel { + background: transparent; + color: var(--color-text); + border: 1px solid rgba(212, 222, 239, 0.8); +} + +.time-limit-confirm-btn.cancel:hover { + background: var(--color-surface-muted); +} + +@media (max-width: 768px) { + .time-limit-manager { + padding: 16px; + } + + .time-limit-input-group { + flex-direction: column; + align-items: stretch; + } + + .time-limit-input { + max-width: 100%; + } + + .time-limit-stats { + flex-direction: column; + gap: 8px; + } + + .time-limit-confirm-modal { + padding: 20px; + } + + .time-limit-confirm-actions { + flex-direction: column; + } +} diff --git a/frontend/src/components/TimeLimitManager/TimeLimitManager.tsx b/frontend/src/components/TimeLimitManager/TimeLimitManager.tsx new file mode 100644 index 0000000..b5cad16 --- /dev/null +++ b/frontend/src/components/TimeLimitManager/TimeLimitManager.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect } from 'react'; +import { + getDailyLimit, + getTimeUsedToday, + setDailyLimit, + resetDailyCounter +} from '../../services/timeLimitService'; +import './TimeLimitManager.css'; + +export function TimeLimitManager() { + const [dailyLimit, setDailyLimitState] = useState(null); + const [timeUsed, setTimeUsed] = useState(getTimeUsedToday()); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [error, setError] = useState(null); + + // Fetch limit from server on mount + useEffect(() => { + const fetchLimit = async () => { + try { + setIsLoading(true); + const limit = await getDailyLimit(); + setDailyLimitState(limit); + setInputValue(limit.toString()); + } catch (err: any) { + setError(err.error?.message || 'Failed to load time limit'); + } finally { + setIsLoading(false); + } + }; + fetchLimit(); + }, []); + + // Update time used periodically + useEffect(() => { + const interval = setInterval(() => { + setTimeUsed(getTimeUsedToday()); + }, 5000); + + return () => clearInterval(interval); + }, []); + + const handleSaveLimit = async () => { + const minutes = parseInt(inputValue, 10); + if (isNaN(minutes) || minutes < 1) { + alert('Please enter a valid number of minutes (minimum 1)'); + return; + } + + setIsSaving(true); + setError(null); + try { + await setDailyLimit(minutes); + setDailyLimitState(minutes); + } catch (err: any) { + setError(err.error?.message || 'Failed to save time limit'); + } finally { + setIsSaving(false); + } + }; + + const handleResetCounter = () => { + resetDailyCounter(); + setTimeUsed(0); + setShowResetConfirm(false); + }; + + const formatTime = (minutes: number): string => { + if (minutes < 1) { + return `${Math.round(minutes * 60)} seconds`; + } + if (minutes < 60) { + return `${Math.round(minutes)} minute${Math.round(minutes) !== 1 ? 's' : ''}`; + } + const hours = Math.floor(minutes / 60); + const mins = Math.round(minutes % 60); + if (mins === 0) { + return `${hours} hour${hours !== 1 ? 's' : ''}`; + } + return `${hours} hour${hours !== 1 ? 's' : ''} ${mins} minute${mins !== 1 ? 's' : ''}`; + }; + + const remainingTime = dailyLimit !== null ? Math.max(0, dailyLimit - timeUsed) : 0; + + if (isLoading) { + return ( +
+
+

Daily Time Limit Settings

+

Loading...

+
+
+ ); + } + + return ( +
+
+

Daily Time Limit Settings

+

Configure how much time users can spend watching videos each day

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ setInputValue(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSaveLimit(); + } + }} + className="time-limit-input" + /> + +
+ {dailyLimit !== null && ( +

+ Current limit: {formatTime(dailyLimit)} per day +

+ )} +
+ + {dailyLimit !== null && ( +
+

Today's Usage

+
+
+
+
+
+ + Used: {formatTime(timeUsed)} + + + Remaining: {formatTime(remainingTime)} + +
+
+ + {timeUsed > 0 && ( +
+ +
+ )} +
+ )} +
+ + {showResetConfirm && ( +
setShowResetConfirm(false)}> +
e.stopPropagation()}> +

Reset Today's Counter?

+

+ This will reset the time used today back to 0. Users will be able to watch videos again. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/VideoCard/VideoCard.css b/frontend/src/components/VideoCard/VideoCard.css index 985f07c..b123779 100644 --- a/frontend/src/components/VideoCard/VideoCard.css +++ b/frontend/src/components/VideoCard/VideoCard.css @@ -1,3 +1,9 @@ +.video-card.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + .video-card { cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; diff --git a/frontend/src/components/VideoCard/VideoCard.tsx b/frontend/src/components/VideoCard/VideoCard.tsx index 50282c0..537b115 100644 --- a/frontend/src/components/VideoCard/VideoCard.tsx +++ b/frontend/src/components/VideoCard/VideoCard.tsx @@ -4,9 +4,10 @@ import './VideoCard.css'; interface VideoCardProps { video: Video; onClick: () => void; + disabled?: boolean; } -export function VideoCard({ video, onClick }: VideoCardProps) { +export function VideoCard({ video, onClick, disabled = false }: VideoCardProps) { const formatViews = (count: number): string => { if (count >= 1000000) { return `${(count / 1000000).toFixed(1)}M`; @@ -31,7 +32,7 @@ export function VideoCard({ video, onClick }: VideoCardProps) { }; return ( -
+
void; + disabled?: boolean; } export function VideoGrid({ @@ -19,7 +20,8 @@ export function VideoGrid({ onVideoClick, page, totalPages, - onPageChange + onPageChange, + disabled = false }: VideoGridProps) { if (loading) { return ( @@ -59,12 +61,13 @@ export function VideoGrid({ return (
-
+
{videos.map(video => ( onVideoClick(video.id)} + onClick={() => !disabled && onVideoClick(video.id)} + disabled={disabled} /> ))}
diff --git a/frontend/src/components/VideoPlayer/VideoPlayer.css b/frontend/src/components/VideoPlayer/VideoPlayer.css index 453e361..ffe3b89 100644 --- a/frontend/src/components/VideoPlayer/VideoPlayer.css +++ b/frontend/src/components/VideoPlayer/VideoPlayer.css @@ -58,6 +58,58 @@ border: none; } +.time-remaining-indicator { + position: absolute; + top: 10px; + left: 10px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; + z-index: 1002; + font-weight: 500; +} + +.time-limit-message { + padding: 60px 40px; + text-align: center; + color: white; + min-height: 400px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.time-limit-message h2 { + font-size: 28px; + margin-bottom: 16px; + color: #ff6b6b; +} + +.time-limit-message p { + font-size: 18px; + margin-bottom: 24px; + opacity: 0.9; +} + +.time-limit-button { + background-color: #4a90e2; + color: white; + border: none; + padding: 12px 24px; + border-radius: 6px; + font-size: 16px; + cursor: pointer; + font-weight: 500; + transition: background-color 0.2s; +} + +.time-limit-button:hover { + background-color: #357abd; +} + @media (max-width: 768px) { .modal-overlay { padding: 0; @@ -77,6 +129,25 @@ height: 36px; font-size: 32px; } + + .time-remaining-indicator { + top: 50px; + left: 10px; + font-size: 12px; + padding: 6px 10px; + } + + .time-limit-message { + padding: 40px 20px; + } + + .time-limit-message h2 { + font-size: 24px; + } + + .time-limit-message p { + font-size: 16px; + } } diff --git a/frontend/src/components/VideoPlayer/VideoPlayer.tsx b/frontend/src/components/VideoPlayer/VideoPlayer.tsx index 11822f2..027433f 100644 --- a/frontend/src/components/VideoPlayer/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer/VideoPlayer.tsx @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { useTimeLimit } from '../../hooks/useTimeLimit'; import './VideoPlayer.css'; interface VideoPlayerProps { @@ -7,36 +8,94 @@ interface VideoPlayerProps { } export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) { + const { limitReached, startTracking, stopTracking, remainingTime } = useTimeLimit(); + const iframeRef = useRef(null); + const checkLimitIntervalRef = useRef(null); + useEffect(() => { // Prevent body scroll document.body.style.overflow = 'hidden'; // Handle Escape key const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') { + stopTracking(); + onClose(); + } }; window.addEventListener('keydown', handleEscape); + // Start tracking time when player opens + if (!limitReached) { + startTracking(); + } + + // Check limit periodically and stop video if reached + checkLimitIntervalRef.current = setInterval(() => { + if (limitReached && iframeRef.current) { + // Stop the video by removing autoplay and reloading with paused state + if (iframeRef.current.src.includes('autoplay=1')) { + iframeRef.current.src = iframeRef.current.src.replace('autoplay=1', 'autoplay=0'); + } + stopTracking(); + } + }, 1000); + return () => { document.body.style.overflow = 'unset'; window.removeEventListener('keydown', handleEscape); + stopTracking(); + if (checkLimitIntervalRef.current) { + clearInterval(checkLimitIntervalRef.current); + } }; - }, [onClose]); + }, [onClose, limitReached, startTracking, stopTracking]); + + // Stop video immediately if limit reached + useEffect(() => { + if (limitReached && iframeRef.current) { + // Change iframe src to stop autoplay + const currentSrc = iframeRef.current.src; + if (currentSrc.includes('autoplay=1')) { + iframeRef.current.src = currentSrc.replace('autoplay=1', 'autoplay=0'); + } + stopTracking(); + } + }, [limitReached, stopTracking]); + const handleClose = () => { + stopTracking(); + onClose(); + }; + return ( -
+
e.stopPropagation()}> - -
-