Browse Source

add time limit feature

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
e7b61fb673
  1. 60
      backend/src/controllers/settings.controller.ts
  2. 2
      backend/src/index.ts
  3. 11
      backend/src/routes/settings.routes.ts
  4. 6
      frontend/src/components/ChannelManager/ChannelManager.css
  5. 268
      frontend/src/components/TimeLimitManager/TimeLimitManager.css
  6. 208
      frontend/src/components/TimeLimitManager/TimeLimitManager.tsx
  7. 6
      frontend/src/components/VideoCard/VideoCard.css
  8. 5
      frontend/src/components/VideoCard/VideoCard.tsx
  9. 4
      frontend/src/components/VideoGrid/VideoGrid.css
  10. 9
      frontend/src/components/VideoGrid/VideoGrid.tsx
  11. 71
      frontend/src/components/VideoPlayer/VideoPlayer.css
  12. 71
      frontend/src/components/VideoPlayer/VideoPlayer.tsx
  13. 153
      frontend/src/hooks/useTimeLimit.ts
  14. 20
      frontend/src/pages/AdminPage.css
  15. 10
      frontend/src/pages/AdminPage.tsx
  16. 13
      frontend/src/pages/VideoApp.css
  17. 18
      frontend/src/pages/VideoApp.tsx
  18. 8
      frontend/src/services/apiClient.ts
  19. 170
      frontend/src/services/timeLimitService.ts

60
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'
}
});
}
}

2
backend/src/index.ts

@ -7,6 +7,7 @@ import { createInitialAdmin } from './setup/initialSetup.js';
import authRoutes from './routes/auth.routes.js'; import authRoutes from './routes/auth.routes.js';
import channelRoutes from './routes/channels.routes.js'; import channelRoutes from './routes/channels.routes.js';
import videoRoutes from './routes/videos.routes.js'; import videoRoutes from './routes/videos.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import { errorHandler } from './middleware/errorHandler.js'; import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js'; import { apiLimiter } from './middleware/rateLimiter.js';
@ -44,6 +45,7 @@ async function startServer() {
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/channels', channelRoutes); app.use('/api/channels', channelRoutes);
app.use('/api/videos', videoRoutes); app.use('/api/videos', videoRoutes);
app.use('/api/settings', settingsRoutes);
// Error handling // Error handling
app.use(errorHandler); app.use(errorHandler);

11
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;

6
frontend/src/components/ChannelManager/ChannelManager.css

@ -1,7 +1,9 @@
.channel-manager { .channel-manager {
max-width: 1000px; width: 100%;
margin: 0 auto;
padding: 24px; padding: 24px;
background: var(--color-surface);
border-radius: 12px;
border: 1px solid rgba(212, 222, 239, 0.8);
} }
.channel-manager h2 { .channel-manager h2 {

268
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;
}
}

208
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<number | null>(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<string | null>(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 (
<div className="time-limit-manager">
<div className="time-limit-header">
<h2>Daily Time Limit Settings</h2>
<p>Loading...</p>
</div>
</div>
);
}
return (
<div className="time-limit-manager">
<div className="time-limit-header">
<h2>Daily Time Limit Settings</h2>
<p>Configure how much time users can spend watching videos each day</p>
</div>
{error && (
<div className="alert alert-error" style={{ marginBottom: '16px' }}>
{error}
</div>
)}
<div className="time-limit-section">
<div className="time-limit-setting">
<label htmlFor="daily-limit-input">
Daily Limit (minutes)
</label>
<div className="time-limit-input-group">
<input
id="daily-limit-input"
type="number"
min="1"
step="1"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSaveLimit();
}
}}
className="time-limit-input"
/>
<button
onClick={handleSaveLimit}
disabled={isSaving || (dailyLimit !== null && inputValue === dailyLimit.toString())}
className="time-limit-save-btn"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
{dailyLimit !== null && (
<p className="time-limit-hint">
Current limit: <strong>{formatTime(dailyLimit)}</strong> per day
</p>
)}
</div>
{dailyLimit !== null && (
<div className="time-limit-status">
<h3>Today's Usage</h3>
<div className="time-limit-progress">
<div className="time-limit-progress-bar">
<div
className="time-limit-progress-fill"
style={{
width: `${Math.min(100, (timeUsed / dailyLimit) * 100)}%`
}}
/>
</div>
<div className="time-limit-stats">
<span className="time-used">
Used: <strong>{formatTime(timeUsed)}</strong>
</span>
<span className="time-remaining">
Remaining: <strong>{formatTime(remainingTime)}</strong>
</span>
</div>
</div>
{timeUsed > 0 && (
<div className="time-limit-actions">
<button
onClick={() => setShowResetConfirm(true)}
className="time-limit-reset-btn"
>
Reset Today's Counter
</button>
</div>
)}
</div>
)}
</div>
{showResetConfirm && (
<div className="time-limit-confirm-overlay" onClick={() => setShowResetConfirm(false)}>
<div className="time-limit-confirm-modal" onClick={(e) => e.stopPropagation()}>
<h3>Reset Today's Counter?</h3>
<p>
This will reset the time used today back to 0. Users will be able to watch videos again.
</p>
<div className="time-limit-confirm-actions">
<button
onClick={handleResetCounter}
className="time-limit-confirm-btn confirm"
>
Reset Counter
</button>
<button
onClick={() => setShowResetConfirm(false)}
className="time-limit-confirm-btn cancel"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

6
frontend/src/components/VideoCard/VideoCard.css

@ -1,3 +1,9 @@
.video-card.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.video-card { .video-card {
cursor: pointer; cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;

5
frontend/src/components/VideoCard/VideoCard.tsx

@ -4,9 +4,10 @@ import './VideoCard.css';
interface VideoCardProps { interface VideoCardProps {
video: Video; video: Video;
onClick: () => void; onClick: () => void;
disabled?: boolean;
} }
export function VideoCard({ video, onClick }: VideoCardProps) { export function VideoCard({ video, onClick, disabled = false }: VideoCardProps) {
const formatViews = (count: number): string => { const formatViews = (count: number): string => {
if (count >= 1000000) { if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`; return `${(count / 1000000).toFixed(1)}M`;
@ -31,7 +32,7 @@ export function VideoCard({ video, onClick }: VideoCardProps) {
}; };
return ( return (
<div className="video-card" onClick={onClick}> <div className={`video-card ${disabled ? 'disabled' : ''}`} onClick={disabled ? undefined : onClick}>
<div className="video-thumbnail-container"> <div className="video-thumbnail-container">
<img <img
src={video.thumbnailUrl} src={video.thumbnailUrl}

4
frontend/src/components/VideoGrid/VideoGrid.css

@ -1,3 +1,7 @@
.video-grid.disabled {
pointer-events: none;
}
.video-grid { .video-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));

9
frontend/src/components/VideoGrid/VideoGrid.tsx

@ -10,6 +10,7 @@ interface VideoGridProps {
page: number; page: number;
totalPages: number; totalPages: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
disabled?: boolean;
} }
export function VideoGrid({ export function VideoGrid({
@ -19,7 +20,8 @@ export function VideoGrid({
onVideoClick, onVideoClick,
page, page,
totalPages, totalPages,
onPageChange onPageChange,
disabled = false
}: VideoGridProps) { }: VideoGridProps) {
if (loading) { if (loading) {
return ( return (
@ -59,12 +61,13 @@ export function VideoGrid({
return ( return (
<div> <div>
<div className="video-grid"> <div className={`video-grid ${disabled ? 'disabled' : ''}`}>
{videos.map(video => ( {videos.map(video => (
<VideoCard <VideoCard
key={video.id} key={video.id}
video={video} video={video}
onClick={() => onVideoClick(video.id)} onClick={() => !disabled && onVideoClick(video.id)}
disabled={disabled}
/> />
))} ))}
</div> </div>

71
frontend/src/components/VideoPlayer/VideoPlayer.css

@ -58,6 +58,58 @@
border: none; 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) { @media (max-width: 768px) {
.modal-overlay { .modal-overlay {
padding: 0; padding: 0;
@ -77,6 +129,25 @@
height: 36px; height: 36px;
font-size: 32px; 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;
}
} }

71
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'; import './VideoPlayer.css';
interface VideoPlayerProps { interface VideoPlayerProps {
@ -7,36 +8,94 @@ interface VideoPlayerProps {
} }
export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) { export function VideoPlayer({ videoId, onClose }: VideoPlayerProps) {
const { limitReached, startTracking, stopTracking, remainingTime } = useTimeLimit();
const iframeRef = useRef<HTMLIFrameElement>(null);
const checkLimitIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
// Prevent body scroll // Prevent body scroll
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Handle Escape key // Handle Escape key
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') {
stopTracking();
onClose();
}
}; };
window.addEventListener('keydown', handleEscape); 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 () => { return () => {
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleEscape); window.removeEventListener('keydown', handleEscape);
stopTracking();
if (checkLimitIntervalRef.current) {
clearInterval(checkLimitIntervalRef.current);
}
};
}, [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();
}; };
}, [onClose]);
return ( return (
<div className="modal-overlay" onClick={onClose}> <div className="modal-overlay" onClick={handleClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}> <div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="close-button" onClick={onClose}>×</button> <button className="close-button" onClick={handleClose}>×</button>
{limitReached ? (
<div className="time-limit-message">
<h2>Daily Time Limit Reached</h2>
<p>You've reached your daily video watching limit. Come back tomorrow!</p>
<button onClick={handleClose} className="time-limit-button">Close</button>
</div>
) : (
<>
<div className="time-remaining-indicator">
{Math.floor(remainingTime)} min remaining today
</div>
<div className="video-container"> <div className="video-container">
<iframe <iframe
ref={iframeRef}
width="100%" width="100%"
height="100%" height="100%"
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`} src={`https://www.youtube.com/embed/${videoId}?autoplay=1&enablejsapi=1`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
title="YouTube video player" title="YouTube video player"
/> />
</div> </div>
</>
)}
</div> </div>
</div> </div>
); );

153
frontend/src/hooks/useTimeLimit.ts

@ -0,0 +1,153 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
getDailyLimit,
getDailyLimitSync,
getTimeUsedToday,
getRemainingTimeToday,
isLimitReached,
addTimeSpent
} from '../services/timeLimitService';
interface UseTimeLimitReturn {
dailyLimit: number;
timeUsed: number;
remainingTime: number;
limitReached: boolean;
startTracking: () => void;
stopTracking: () => void;
isTracking: boolean;
}
/**
* Hook to manage video time limits
* Tracks time spent watching videos and enforces daily limits
*/
export function useTimeLimit(): UseTimeLimitReturn {
const [dailyLimit, setDailyLimit] = useState(getDailyLimitSync());
const [timeUsed, setTimeUsed] = useState(getTimeUsedToday());
const [remainingTime, setRemainingTime] = useState(getRemainingTimeToday());
const [limitReached, setLimitReached] = useState(isLimitReached());
const [isTracking, setIsTracking] = useState(false);
const trackingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number | null>(null);
const lastUpdateRef = useRef<number>(Date.now());
// Fetch limit from server on mount
useEffect(() => {
getDailyLimit().then(limit => {
setDailyLimit(limit);
});
}, []);
// Update state from localStorage and cached server limit
const updateState = useCallback(() => {
setDailyLimit(getDailyLimitSync());
setTimeUsed(getTimeUsedToday());
setRemainingTime(getRemainingTimeToday());
setLimitReached(isLimitReached());
}, []);
// Start tracking time
const startTracking = useCallback(() => {
if (limitReached) {
return; // Don't start if limit already reached
}
if (trackingIntervalRef.current) {
return; // Already tracking
}
startTimeRef.current = Date.now();
lastUpdateRef.current = Date.now();
setIsTracking(true);
// Update every 5 seconds
trackingIntervalRef.current = setInterval(() => {
if (startTimeRef.current) {
const now = Date.now();
const secondsElapsed = (now - lastUpdateRef.current) / 1000;
lastUpdateRef.current = now;
// Add time spent
addTimeSpent(secondsElapsed);
// Update state
updateState();
// Check if limit reached during tracking
if (isLimitReached()) {
// Stop tracking if limit reached
if (trackingIntervalRef.current) {
clearInterval(trackingIntervalRef.current);
trackingIntervalRef.current = null;
}
if (startTimeRef.current && lastUpdateRef.current) {
const secondsElapsed = (Date.now() - lastUpdateRef.current) / 1000;
if (secondsElapsed > 0) {
addTimeSpent(secondsElapsed);
}
startTimeRef.current = null;
}
setIsTracking(false);
updateState();
}
}
}, 5000);
}, [limitReached, updateState]);
// Stop tracking time
const stopTracking = useCallback(() => {
if (trackingIntervalRef.current) {
clearInterval(trackingIntervalRef.current);
trackingIntervalRef.current = null;
}
// Add any remaining time
if (startTimeRef.current && lastUpdateRef.current) {
const secondsElapsed = (Date.now() - lastUpdateRef.current) / 1000;
if (secondsElapsed > 0) {
addTimeSpent(secondsElapsed);
}
startTimeRef.current = null;
}
setIsTracking(false);
updateState();
}, [updateState]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (trackingIntervalRef.current) {
clearInterval(trackingIntervalRef.current);
}
// Save any remaining time before unmount
if (startTimeRef.current && lastUpdateRef.current) {
const secondsElapsed = (Date.now() - lastUpdateRef.current) / 1000;
if (secondsElapsed > 0) {
addTimeSpent(secondsElapsed);
}
}
};
}, []);
// Update state periodically to catch external changes
useEffect(() => {
const interval = setInterval(() => {
updateState();
}, 10000); // Check every 10 seconds
return () => clearInterval(interval);
}, [updateState]);
return {
dailyLimit,
timeUsed,
remainingTime,
limitReached,
startTracking,
stopTracking,
isTracking
};
}

20
frontend/src/pages/AdminPage.css

@ -23,5 +23,25 @@
color: #606060; color: #606060;
} }
.admin-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
.admin-column {
display: flex;
flex-direction: column;
}
@media (max-width: 1024px) {
.admin-content {
grid-template-columns: 1fr;
}
}

10
frontend/src/pages/AdminPage.tsx

@ -1,4 +1,5 @@
import { ChannelManager } from '../components/ChannelManager/ChannelManager'; import { ChannelManager } from '../components/ChannelManager/ChannelManager';
import { TimeLimitManager } from '../components/TimeLimitManager/TimeLimitManager';
import './AdminPage.css'; import './AdminPage.css';
export function AdminPage() { export function AdminPage() {
@ -6,11 +7,18 @@ export function AdminPage() {
<div className="admin-page"> <div className="admin-page">
<div className="admin-header"> <div className="admin-header">
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
<p>Manage YouTube channels to display on the home page</p> <p>Manage YouTube channels and video settings</p>
</div> </div>
<div className="admin-content">
<div className="admin-column">
<ChannelManager /> <ChannelManager />
</div> </div>
<div className="admin-column">
<TimeLimitManager />
</div>
</div>
</div>
); );
} }

13
frontend/src/pages/VideoApp.css

@ -0,0 +1,13 @@
.time-limit-banner {
background-color: #ff6b6b;
color: white;
padding: 12px 20px;
text-align: center;
font-weight: 500;
margin-bottom: 20px;
border-radius: 4px;
}
.time-limit-banner p {
margin: 0;
}

18
frontend/src/pages/VideoApp.tsx

@ -1,12 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useVideos } from '../hooks/useVideos'; import { useVideos } from '../hooks/useVideos';
import { useTimeLimit } from '../hooks/useTimeLimit';
import { VideoGrid } from '../components/VideoGrid/VideoGrid'; import { VideoGrid } from '../components/VideoGrid/VideoGrid';
import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer'; import { VideoPlayer } from '../components/VideoPlayer/VideoPlayer';
import './VideoApp.css';
export function VideoApp() { export function VideoApp() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [selectedVideo, setSelectedVideo] = useState<string | null>(null); const [selectedVideo, setSelectedVideo] = useState<string | null>(null);
const { limitReached } = useTimeLimit();
// Read from URL query params // Read from URL query params
const page = parseInt(searchParams.get('page') || '1', 10); const page = parseInt(searchParams.get('page') || '1', 10);
@ -31,17 +34,30 @@ export function VideoApp() {
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
}; };
const handleVideoClick = (videoId: string) => {
if (limitReached) {
return; // Don't allow video to open if limit reached
}
setSelectedVideo(videoId);
};
return ( return (
<div> <div>
{limitReached && (
<div className="time-limit-banner">
<p>Daily time limit reached. Videos are disabled until tomorrow.</p>
</div>
)}
<VideoGrid <VideoGrid
videos={videos} videos={videos}
loading={loading} loading={loading}
error={error} error={error}
onVideoClick={setSelectedVideo} onVideoClick={handleVideoClick}
page={page} page={page}
totalPages={meta.totalPages} totalPages={meta.totalPages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
disabled={limitReached}
/> />
{selectedVideo && ( {selectedVideo && (

8
frontend/src/services/apiClient.ts

@ -119,5 +119,13 @@ export const videosApi = {
api.post('/videos/refresh', { channelIds }) api.post('/videos/refresh', { channelIds })
}; };
// Settings API
export const settingsApi = {
getTimeLimit: () => api.get('/settings/time-limit'),
setTimeLimit: (dailyLimit: number) =>
api.put('/settings/time-limit', { dailyLimit })
};

170
frontend/src/services/timeLimitService.ts

@ -0,0 +1,170 @@
interface TimeLimitData {
dailyLimit: number; // minutes (stored on server)
dailyTimeUsed: number; // minutes (stored per-device in localStorage)
lastResetDate: string; // ISO date string (YYYY-MM-DD)
}
const STORAGE_KEY = 'video_time_limit';
const DEFAULT_DAILY_LIMIT = 1; // 1 minute for testing
// Cache for daily limit from server
let cachedDailyLimit: number | null = null;
let limitCacheTime: number = 0;
const LIMIT_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Get time limit data from localStorage (for usage tracking only)
*/
function getTimeLimitData(): Omit<TimeLimitData, 'dailyLimit'> {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const data = JSON.parse(stored);
return {
dailyTimeUsed: data.dailyTimeUsed || 0,
lastResetDate: data.lastResetDate || new Date().toISOString().split('T')[0]
};
}
} catch (e) {
console.warn('Failed to parse time limit data from localStorage', e);
}
// Return default data
return {
dailyTimeUsed: 0,
lastResetDate: new Date().toISOString().split('T')[0]
};
}
/**
* Save time limit data to localStorage (for usage tracking only)
*/
function saveTimeLimitData(data: Omit<TimeLimitData, 'dailyLimit'>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (e) {
console.warn('Failed to save time limit data to localStorage', e);
}
}
/**
* Check if we need to reset daily counter (new day)
*/
function shouldResetDaily(): boolean {
const data = getTimeLimitData();
const today = new Date().toISOString().split('T')[0];
return data.lastResetDate !== today;
}
/**
* Reset daily counter if it's a new day
*/
function resetIfNeeded(): void {
if (shouldResetDaily()) {
const data = getTimeLimitData();
data.dailyTimeUsed = 0;
data.lastResetDate = new Date().toISOString().split('T')[0];
saveTimeLimitData(data);
}
}
/**
* Get current time limit configuration from server
* Falls back to cached value or default if server unavailable
*/
export async function getDailyLimit(): Promise<number> {
// Return cached value if still valid
const now = Date.now();
if (cachedDailyLimit !== null && (now - limitCacheTime) < LIMIT_CACHE_DURATION) {
return cachedDailyLimit;
}
try {
const { settingsApi } = await import('./apiClient');
const response = await settingsApi.getTimeLimit();
cachedDailyLimit = response.data.dailyLimit;
limitCacheTime = now;
return cachedDailyLimit;
} catch (error) {
console.warn('Failed to fetch daily limit from server, using cached/default:', error);
// Return cached value or default
return cachedDailyLimit ?? DEFAULT_DAILY_LIMIT;
}
}
/**
* Set daily time limit (in minutes) on server
*/
export async function setDailyLimit(minutes: number): Promise<void> {
try {
const { settingsApi } = await import('./apiClient');
await settingsApi.setTimeLimit(minutes);
cachedDailyLimit = minutes;
limitCacheTime = Date.now();
} catch (error) {
console.error('Failed to set daily limit on server:', error);
throw error;
}
}
/**
* Synchronous version for use in hooks (uses cached value)
*/
export function getDailyLimitSync(): number {
return cachedDailyLimit ?? DEFAULT_DAILY_LIMIT;
}
/**
* Get time used today (in minutes) - per device
*/
export function getTimeUsedToday(): number {
resetIfNeeded();
const data = getTimeLimitData();
return data.dailyTimeUsed;
}
/**
* Get remaining time today (in minutes)
* Note: Uses cached limit value
*/
export function getRemainingTimeToday(): number {
resetIfNeeded();
const limit = getDailyLimitSync();
const used = getTimeUsedToday();
return Math.max(0, limit - used);
}
/**
* Check if daily limit has been reached
* Note: Uses cached limit value
*/
export function isLimitReached(): boolean {
resetIfNeeded();
return getRemainingTimeToday() <= 0;
}
/**
* Add time spent (in seconds) to the daily counter
* Note: Uses cached limit value to cap the usage
*/
export function addTimeSpent(seconds: number): void {
resetIfNeeded();
const data = getTimeLimitData();
const minutesToAdd = seconds / 60;
const limit = getDailyLimitSync();
data.dailyTimeUsed = Math.min(
limit,
data.dailyTimeUsed + minutesToAdd
);
saveTimeLimitData(data);
}
/**
* Reset daily counter (for testing/admin purposes)
*/
export function resetDailyCounter(): void {
const data = getTimeLimitData();
data.dailyTimeUsed = 0;
data.lastResetDate = new Date().toISOString().split('T')[0];
saveTimeLimitData(data);
}
Loading…
Cancel
Save