Compare commits

..

1 Commits

Author SHA1 Message Date
Stephanie Gredell 88b400b464 remove time limit feature 1 month ago
  1. 6
      README.md
  2. 130
      backend/src/controllers/auth.controller.ts
  3. 8
      backend/src/controllers/magicCode.controller.ts
  4. 67
      backend/src/controllers/settings.controller.ts
  5. 73
      backend/src/controllers/settingsProfiles.controller.ts
  6. 24
      backend/src/middleware/validation.ts
  7. 5
      backend/src/routes/auth.routes.ts
  8. 8
      backend/src/routes/settings.routes.ts
  9. 8
      backend/src/services/connection-tracker.service.ts
  10. BIN
      frontend/public/drawing.png
  11. BIN
      frontend/public/juice.png
  12. 2
      frontend/src/App.tsx
  13. 2
      frontend/src/components/Footer/Footer.tsx
  14. 12
      frontend/src/components/MagicCodeInput/MagicCodeInput.tsx
  15. 27
      frontend/src/components/Navbar/Navbar.tsx
  16. 215
      frontend/src/components/TimeLimitManager/TimeLimitManager.tsx
  17. 19
      frontend/src/config/apps.ts
  18. 155
      frontend/src/hooks/useTimeLimit.ts
  19. 7
      frontend/src/pages/DrawingPadApp.tsx
  20. 70
      frontend/src/pages/GameIframeApp.tsx
  21. 66
      frontend/src/pages/LandingPage.tsx
  22. 18
      frontend/src/pages/LoginPage.tsx
  23. 185
      frontend/src/pages/RegisterPage.tsx
  24. 23
      frontend/src/services/apiClient.ts
  25. 8
      frontend/src/services/connectionTracker.ts
  26. 26
      frontend/src/services/magicCodeService.ts
  27. 210
      frontend/src/services/timeLimitService.ts
  28. 1
      frontend/src/types/api.ts

6
README.md

@ -1,3 +1,9 @@
# Kiddos - YouTube Channel Video Aggregator
A full-stack application for aggregating and displaying videos from multiple YouTube channels with a protected admin dashboard.
## Tech Stack
**Frontend:** **Frontend:**
- React 18 with TypeScript - React 18 with TypeScript
- Vite for build tooling - Vite for build tooling

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

@ -1,7 +1,7 @@
import { Response } from 'express'; import { Response } from 'express';
import { AuthRequest } from '../types/index.js'; import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js'; import { db } from '../config/database.js';
import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword, hashPassword } from '../services/auth.service.js'; import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword } from '../services/auth.service.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
@ -150,134 +150,6 @@ export async function logout(req: AuthRequest, res: Response) {
} }
} }
export async function register(req: AuthRequest, res: Response) {
try {
const { username, password, dateOfBirth } = req.body;
if (!username || !password || !dateOfBirth) {
return res.status(400).json({
success: false,
error: {
code: 'MISSING_FIELDS',
message: 'Username, password, and date of birth are required'
}
});
}
// Validate password length
if (password.length < 8) {
return res.status(400).json({
success: false,
error: {
code: 'WEAK_PASSWORD',
message: 'Password must be at least 8 characters long'
}
});
}
// Validate date of birth and age
const birthDate = new Date(dateOfBirth);
if (isNaN(birthDate.getTime())) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_DATE',
message: 'Invalid date of birth'
}
});
}
// Check if user is at least 18 years old
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
const dayDiff = today.getDate() - birthDate.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
if (actualAge < 18) {
return res.status(400).json({
success: false,
error: {
code: 'AGE_RESTRICTION',
message: 'You must be at least 18 years old to register'
}
});
}
// Check if username already exists
const existing = await db.execute({
sql: 'SELECT id FROM users WHERE username = ?',
args: [username]
});
if (existing.rows.length > 0) {
return res.status(409).json({
success: false,
error: {
code: 'USERNAME_EXISTS',
message: 'Username already exists'
}
});
}
// Hash password
const passwordHash = await hashPassword(password);
// Insert user with 'user' role (never 'admin' for public registration)
const result = await db.execute({
sql: 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
args: [username, passwordHash, 'user']
});
// Get created user
const newUserId = Number(result.lastInsertRowid);
const newUser = await db.execute({
sql: 'SELECT id, username, role FROM users WHERE id = ?',
args: [newUserId]
});
// Automatically log in the newly registered user
const { accessToken, refreshToken } = await createTokens(
newUserId,
username
);
// 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.status(201).json({
success: true,
data: {
user: {
id: newUser.rows[0].id,
username: newUser.rows[0].username,
role: newUser.rows[0].role || 'user'
},
accessToken,
refreshToken
}
});
} catch (error: any) {
console.error('Register error:', error);
res.status(500).json({
success: false,
error: {
code: 'REGISTER_ERROR',
message: 'An error occurred during registration'
}
});
}
}
export async function getCurrentUser(req: AuthRequest, res: Response) { export async function getCurrentUser(req: AuthRequest, res: Response) {
try { try {
if (!req.userId) { if (!req.userId) {

8
backend/src/controllers/magicCode.controller.ts

@ -49,8 +49,11 @@ export async function getSettingsByCode(req: Request, res: Response) {
const key = row.setting_key as string; const key = row.setting_key as string;
const value = row.setting_value as string; const value = row.setting_value as string;
// Parse JSON array for enabled_apps // Parse numeric values
if (key === 'enabled_apps') { if (key === 'daily_time_limit_minutes') {
settings[key] = parseInt(value, 10);
} else if (key === 'enabled_apps') {
// Parse JSON array
try { try {
settings[key] = JSON.parse(value); settings[key] = JSON.parse(value);
} catch (e) { } catch (e) {
@ -72,6 +75,7 @@ export async function getSettingsByCode(req: Request, res: Response) {
data: { data: {
magicCode: profile.magic_code, magicCode: profile.magic_code,
settings, settings,
dailyTimeLimit: settings.daily_time_limit_minutes || null,
enabledApps enabledApps
} }
}); });

67
backend/src/controllers/settings.controller.ts

@ -4,6 +4,63 @@ import { getSetting, setSetting } from '../config/database.js';
import { connectionTracker } from '../services/connection-tracker.service.js'; import { connectionTracker } from '../services/connection-tracker.service.js';
import crypto from 'crypto'; import crypto from 'crypto';
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'
}
});
}
}
/** /**
* Heartbeat endpoint - clients ping this to indicate they're active * Heartbeat endpoint - clients ping this to indicate they're active
* Public endpoint - no auth required * Public endpoint - no auth required
@ -28,19 +85,23 @@ export async function heartbeat(req: AuthRequest, res: Response) {
}); });
} }
// Get route and video info from request body // Get route, video info, and time limit usage from request body
const route = req.body.route || '/'; const route = req.body.route || '/';
const videoTitle = req.body.videoTitle; const videoTitle = req.body.videoTitle;
const videoChannel = req.body.videoChannel; const videoChannel = req.body.videoChannel;
const timeUsed = req.body.timeUsed;
const dailyLimit = req.body.dailyLimit;
// Register heartbeat (with user info if authenticated, current route, and video info) // Register heartbeat (with user info if authenticated, current route, video info, and time limit usage)
connectionTracker.heartbeat( connectionTracker.heartbeat(
sessionId, sessionId,
req.userId, req.userId,
req.username, req.username,
route, route,
videoTitle, videoTitle,
videoChannel videoChannel,
timeUsed,
dailyLimit
); );
res.json({ res.json({

73
backend/src/controllers/settingsProfiles.controller.ts

@ -73,6 +73,9 @@ export async function getAllProfiles(req: AuthRequest, res: Response) {
return { return {
...profile, ...profile,
dailyTimeLimit: profile.settings.daily_time_limit_minutes
? parseInt(profile.settings.daily_time_limit_minutes, 10)
: null,
enabledApps enabledApps
}; };
}); });
@ -165,6 +168,9 @@ export async function getProfile(req: AuthRequest, res: Response) {
updatedAt: profile.updated_at, updatedAt: profile.updated_at,
isActive: profile.is_active === 1, isActive: profile.is_active === 1,
settings, settings,
dailyTimeLimit: settings.daily_time_limit_minutes
? parseInt(settings.daily_time_limit_minutes, 10)
: null,
enabledApps enabledApps
} }
}); });
@ -192,7 +198,7 @@ export async function createProfile(req: AuthRequest, res: Response) {
}); });
} }
const { name, description, enabledApps } = req.body; const { name, description, dailyTimeLimit, enabledApps } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) { if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ return res.status(400).json({
@ -204,6 +210,16 @@ export async function createProfile(req: AuthRequest, res: Response) {
}); });
} }
if (!dailyTimeLimit || typeof dailyTimeLimit !== 'number' || dailyTimeLimit < 1) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_TIME_LIMIT',
message: 'Daily time limit must be a number greater than 0'
}
});
}
// Generate unique magic code // Generate unique magic code
const magicCode = await generateMagicCode(); const magicCode = await generateMagicCode();
@ -218,6 +234,15 @@ export async function createProfile(req: AuthRequest, res: Response) {
const profileId = Number(profileResult.lastInsertRowid); const profileId = Number(profileResult.lastInsertRowid);
// Add settings
await db.execute({
sql: `
INSERT INTO settings_profile_values (profile_id, setting_key, setting_value)
VALUES (?, ?, ?)
`,
args: [profileId, 'daily_time_limit_minutes', dailyTimeLimit.toString()]
});
// Add enabled apps if provided // Add enabled apps if provided
if (enabledApps && Array.isArray(enabledApps)) { if (enabledApps && Array.isArray(enabledApps)) {
await db.execute({ await db.execute({
@ -244,7 +269,8 @@ export async function createProfile(req: AuthRequest, res: Response) {
description: createdProfile.rows[0].description, description: createdProfile.rows[0].description,
createdAt: createdProfile.rows[0].created_at, createdAt: createdProfile.rows[0].created_at,
updatedAt: createdProfile.rows[0].updated_at, updatedAt: createdProfile.rows[0].updated_at,
isActive: createdProfile.rows[0].is_active === 1 isActive: createdProfile.rows[0].is_active === 1,
dailyTimeLimit
} }
}); });
} catch (error: any) { } catch (error: any) {
@ -461,7 +487,7 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) {
}); });
} }
const { enabledApps } = req.body; const { dailyTimeLimit, enabledApps } = req.body;
// Verify ownership // Verify ownership
const existing = await db.execute({ const existing = await db.execute({
@ -479,6 +505,30 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) {
}); });
} }
if (dailyTimeLimit !== undefined) {
if (typeof dailyTimeLimit !== 'number' || dailyTimeLimit < 1) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_TIME_LIMIT',
message: 'Daily time limit must be a number greater than 0'
}
});
}
// Update or insert setting
await db.execute({
sql: `
INSERT INTO settings_profile_values (profile_id, setting_key, setting_value, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(profile_id, setting_key) DO UPDATE SET
setting_value = excluded.setting_value,
updated_at = excluded.updated_at
`,
args: [profileId, 'daily_time_limit_minutes', dailyTimeLimit.toString(), new Date().toISOString()]
});
}
if (enabledApps !== undefined) { if (enabledApps !== undefined) {
if (!Array.isArray(enabledApps)) { if (!Array.isArray(enabledApps)) {
return res.status(400).json({ return res.status(400).json({
@ -503,9 +553,24 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) {
}); });
} }
// Get updated settings
const settingsResult = await db.execute({
sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?',
args: [profileId]
});
const settings: Record<string, string> = {};
for (const row of settingsResult.rows) {
settings[row.setting_key as string] = row.setting_value as string;
}
res.json({ res.json({
success: true, success: true,
data: {} data: {
dailyTimeLimit: settings.daily_time_limit_minutes
? parseInt(settings.daily_time_limit_minutes, 10)
: null
}
}); });
} catch (error: any) { } catch (error: any) {
console.error('Update profile settings error:', error); console.error('Update profile settings error:', error);

24
backend/src/middleware/validation.ts

@ -6,30 +6,6 @@ export const loginSchema = z.object({
password: z.string().min(8) password: z.string().min(8)
}); });
export const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(8),
dateOfBirth: z.string().refine((dateStr) => {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
// Check if user is at least 18 years old
const today = new Date();
const age = today.getFullYear() - date.getFullYear();
const monthDiff = today.getMonth() - date.getMonth();
const dayDiff = today.getDate() - date.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
return actualAge >= 18;
}, {
message: 'You must be at least 18 years old to register'
})
});
export const addChannelSchema = z.object({ export const addChannelSchema = z.object({
channelInput: z.string().min(1) channelInput: z.string().min(1)
}); });

5
backend/src/routes/auth.routes.ts

@ -1,12 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { login, register, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js'; import { login, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js';
import { authMiddleware } from '../middleware/auth.js'; import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, loginSchema, registerSchema } from '../middleware/validation.js'; import { validateRequest, loginSchema } from '../middleware/validation.js';
import { loginLimiter } from '../middleware/rateLimiter.js'; import { loginLimiter } from '../middleware/rateLimiter.js';
const router = Router(); const router = Router();
router.post('/register', loginLimiter, validateRequest(registerSchema), register);
router.post('/login', loginLimiter, validateRequest(loginSchema), login); router.post('/login', loginLimiter, validateRequest(loginSchema), login);
router.post('/refresh', refresh); router.post('/refresh', refresh);
router.post('/logout', authMiddleware, logout); router.post('/logout', authMiddleware, logout);

8
backend/src/routes/settings.routes.ts

@ -1,11 +1,17 @@
import { Router } from 'express'; import { Router } from 'express';
import { heartbeat, getConnectionStats } from '../controllers/settings.controller.js'; import { getTimeLimit, setTimeLimit, heartbeat, getConnectionStats } from '../controllers/settings.controller.js';
import { authMiddleware } from '../middleware/auth.js'; import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js'; import { adminMiddleware } from '../middleware/admin.js';
import { optionalAuthMiddleware } from '../middleware/optionalAuth.js'; import { optionalAuthMiddleware } from '../middleware/optionalAuth.js';
const router = Router(); const router = Router();
// Public route - anyone can read the time limit
router.get('/time-limit', getTimeLimit);
// Admin-only route - only admins can set time limits
router.put('/time-limit', authMiddleware, adminMiddleware, setTimeLimit);
// Public route - heartbeat for connection tracking (optional auth to track authenticated users) // Public route - heartbeat for connection tracking (optional auth to track authenticated users)
router.post('/heartbeat', optionalAuthMiddleware, heartbeat); router.post('/heartbeat', optionalAuthMiddleware, heartbeat);

8
backend/src/services/connection-tracker.service.ts

@ -10,6 +10,8 @@ interface Connection {
route?: string; route?: string;
videoTitle?: string; videoTitle?: string;
videoChannel?: string; videoChannel?: string;
timeUsed?: number; // minutes
dailyLimit?: number; // minutes
lastHeartbeat: number; lastHeartbeat: number;
connectedAt: number; connectedAt: number;
} }
@ -29,7 +31,7 @@ class ConnectionTracker {
/** /**
* Register or update a connection heartbeat * Register or update a connection heartbeat
*/ */
heartbeat(sessionId: string, userId?: number, username?: string, route?: string, videoTitle?: string, videoChannel?: string): void { heartbeat(sessionId: string, userId?: number, username?: string, route?: string, videoTitle?: string, videoChannel?: string, timeUsed?: number, dailyLimit?: number): void {
const now = Date.now(); const now = Date.now();
const existing = this.connections.get(sessionId); const existing = this.connections.get(sessionId);
@ -41,6 +43,8 @@ class ConnectionTracker {
if (route !== undefined) existing.route = route; if (route !== undefined) existing.route = route;
if (videoTitle !== undefined) existing.videoTitle = videoTitle; if (videoTitle !== undefined) existing.videoTitle = videoTitle;
if (videoChannel !== undefined) existing.videoChannel = videoChannel; if (videoChannel !== undefined) existing.videoChannel = videoChannel;
if (timeUsed !== undefined) existing.timeUsed = timeUsed;
if (dailyLimit !== undefined) existing.dailyLimit = dailyLimit;
} else { } else {
// New connection // New connection
this.connections.set(sessionId, { this.connections.set(sessionId, {
@ -50,6 +54,8 @@ class ConnectionTracker {
route, route,
videoTitle, videoTitle,
videoChannel, videoChannel,
timeUsed,
dailyLimit,
lastHeartbeat: now, lastHeartbeat: now,
connectedAt: now connectedAt: now
}); });

BIN
frontend/public/drawing.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

BIN
frontend/public/juice.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

2
frontend/src/App.tsx

@ -19,7 +19,6 @@ const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module =
const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage }))); const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage })));
const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage }))); const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage })));
const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage }))); const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage })));
const RegisterPage = lazy(() => import('./pages/RegisterPage').then(module => ({ default: module.RegisterPage })));
// Loading fallback component // Loading fallback component
const PageLoader = () => ( const PageLoader = () => (
@ -68,7 +67,6 @@ function App() {
))} ))}
{/* Keep non-app routes separate */} {/* Keep non-app routes separate */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route <Route
path="/admin" path="/admin"
element={ element={

2
frontend/src/components/Footer/Footer.tsx

@ -1,6 +1,6 @@
export function Footer() { export function Footer() {
return ( return (
<footer className="bg-muted border-t border-border mt-auto"> <footer className="bg-muted border-t border-border mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex items-center justify-center">
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
© 2025 Rainbows, Cupcakes and Unicorns. Free fun for all children. No ads, no logins, no worries! 🎓 © 2025 Rainbows, Cupcakes and Unicorns. Free fun for all children. No ads, no logins, no worries! 🎓

12
frontend/src/components/MagicCodeInput/MagicCodeInput.tsx

@ -64,6 +64,13 @@ export function MagicCodeInput({ onApplied, onClose }: MagicCodeInputProps) {
} }
}; };
const formatTime = (minutes: number | null) => {
if (!minutes) return 'Not set';
if (minutes < 60) return `${minutes} minutes`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours} hour${hours !== 1 ? 's' : ''} ${mins} minute${mins !== 1 ? 's' : ''}` : `${hours} hour${hours !== 1 ? 's' : ''}`;
};
return ( return (
<div className="bg-card rounded-xl p-6 border border-border max-w-md w-full"> <div className="bg-card rounded-xl p-6 border border-border max-w-md w-full">
@ -78,6 +85,11 @@ export function MagicCodeInput({ onApplied, onClose }: MagicCodeInputProps) {
<p className="text-xs text-green-700"> <p className="text-xs text-green-700">
Code: <strong>{appliedCode}</strong> Code: <strong>{appliedCode}</strong>
</p> </p>
{settings.dailyTimeLimit && (
<p className="text-xs text-green-700 mt-1">
Daily time limit: <strong>{formatTime(settings.dailyTimeLimit)}</strong>
</p>
)}
<button <button
onClick={handleClear} onClick={handleClear}
className="mt-2 text-xs text-green-700 hover:underline" className="mt-2 text-xs text-green-700 hover:underline"

27
frontend/src/components/Navbar/Navbar.tsx

@ -97,7 +97,8 @@ export function Navbar() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/" to="/"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/' className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${
location.pathname === '/'
? 'bg-primary text-primary-foreground shadow-md' ? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50' : 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`} }`}
@ -105,22 +106,11 @@ export function Navbar() {
Home Home
</Link> </Link>
{!isAuthenticated && (
<Link
to="/register"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/register'
? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`}
>
Sign In / Register
</Link>
)}
{isAdmin && ( {isAdmin && (
<Link <Link
to="/admin" to="/admin"
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname.startsWith('/admin') className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${
location.pathname.startsWith('/admin')
? 'bg-primary text-primary-foreground shadow-md' ? 'bg-primary text-primary-foreground shadow-md'
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50' : 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
}`} }`}
@ -129,13 +119,20 @@ export function Navbar() {
</Link> </Link>
)} )}
{isAuthenticated && ( {isAuthenticated ? (
<button <button
onClick={handleLogout} onClick={handleLogout}
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md" className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md"
> >
Logout Logout
</button> </button>
) : (
<Link
to="/login"
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md"
>
Login
</Link>
)} )}
</div> </div>
</div> </div>

215
frontend/src/components/TimeLimitManager/TimeLimitManager.tsx

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import {
getDailyLimit,
getTimeUsedToday,
setDailyLimit,
resetDailyCounter
} from '../../services/timeLimitService';
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="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p className="m-0 text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="bg-card rounded-xl p-6 border border-border h-fit">
<div className="mb-6">
<h2 className="m-0 mb-2 text-xl font-semibold text-foreground">Daily Time Limit Settings</h2>
<p className="m-0 text-sm text-muted-foreground">
Configure how much time users can spend watching videos each day
</p>
</div>
{error && (
<div className="px-4 py-3 rounded-md mb-4 text-sm bg-red-50 text-red-800 border border-red-200">
{error}
</div>
)}
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<label htmlFor="daily-limit-input" className="text-sm font-medium text-foreground">
Daily Limit (minutes)
</label>
<div className="flex gap-3 items-center md:flex-row flex-col md:items-center items-stretch">
<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="flex-1 max-w-[200px] md:max-w-[200px] max-w-full px-3 py-2.5 border border-border rounded-md text-sm bg-muted text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<button
onClick={handleSaveLimit}
disabled={isSaving || (dailyLimit !== null && inputValue === dailyLimit.toString())}
className="px-5 py-2.5 bg-gradient-to-r from-primary to-secondary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
{dailyLimit !== null && (
<p className="m-0 text-[13px] text-muted-foreground">
Current limit: <strong className="font-semibold">{formatTime(dailyLimit)}</strong> per day
</p>
)}
</div>
{dailyLimit !== null && (
<div className="p-5 bg-muted rounded-lg border border-border/50">
<h3 className="m-0 mb-4 text-base font-semibold text-foreground">Today's Usage</h3>
<div className="flex flex-col gap-3">
<div className="w-full h-6 bg-border/30 rounded-xl overflow-hidden relative">
<div
className="h-full bg-gradient-to-r from-primary to-secondary transition-all duration-300 ease-in-out rounded-xl"
style={{
width: `${Math.min(100, (timeUsed / dailyLimit) * 100)}%`
}}
/>
</div>
<div className="flex justify-between text-sm text-foreground md:flex-row flex-col md:gap-0 gap-2">
<span className="time-used">
Used: <strong className="text-primary font-semibold">{formatTime(timeUsed)}</strong>
</span>
<span className="time-remaining">
Remaining: <strong className="text-primary font-semibold">{formatTime(remainingTime)}</strong>
</span>
</div>
</div>
{timeUsed > 0 && (
<div className="mt-4 pt-4 border-t border-border/50">
<button
onClick={() => setShowResetConfirm(true)}
className="px-4 py-2 bg-transparent text-primary border border-primary rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-primary hover:text-primary-foreground"
>
Reset Today's Counter
</button>
</div>
)}
</div>
)}
</div>
{showResetConfirm && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000] backdrop-blur-sm"
onClick={() => setShowResetConfirm(false)}
>
<div
className="bg-card rounded-xl p-6 max-w-[400px] w-[90%] shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="m-0 mb-3 text-lg font-semibold text-foreground">Reset Today's Counter?</h3>
<p className="m-0 mb-5 text-sm text-muted-foreground leading-relaxed">
This will reset the time used today back to 0. Users will be able to watch videos again.
</p>
<div className="flex gap-3 justify-end md:flex-row flex-col">
<button
onClick={handleResetCounter}
className="px-5 py-2.5 bg-gradient-to-r from-primary to-secondary text-white border-none rounded-md text-sm font-medium cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg"
>
Reset Counter
</button>
<button
onClick={() => setShowResetConfirm(false)}
className="px-5 py-2.5 bg-transparent text-foreground border border-border rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-muted"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}

19
frontend/src/config/apps.ts

@ -5,16 +5,6 @@ const VideoApp = lazy(() => import('../pages/VideoApp').then(module => ({ defaul
const SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp }))); const SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp })));
const TicTacToeApp = lazy(() => import('../pages/TicTacToeApp').then(module => ({ default: module.TicTacToeApp }))); const TicTacToeApp = lazy(() => import('../pages/TicTacToeApp').then(module => ({ default: module.TicTacToeApp })));
const DrawingPadApp = lazy(() => import('../pages/DrawingPadApp').then(module => ({ default: module.DrawingPadApp }))); const DrawingPadApp = lazy(() => import('../pages/DrawingPadApp').then(module => ({ default: module.DrawingPadApp })));
const GameIframeApp = lazy(() => import('../pages/GameIframeApp').then(module => ({ default: module.GameIframeApp })));
const LemonadeStandApp: React.FC = () =>
React.createElement(GameIframeApp, {
iframeUrl: 'https://shark-app-su4b5.ondigitalocean.app/',
badgeText: 'Arcade Corner',
title: 'Lemonade Stand',
description: 'Watch the forecast, buy supplies, and try to turn a profit.',
footerNote: 'Hosted externally on DigitalOcean.'
});
export type App = { export type App = {
id: string; id: string;
@ -62,14 +52,5 @@ export const APPS: App[] = [
link: '/drawing-pad', link: '/drawing-pad',
disabled: false, disabled: false,
component: DrawingPadApp component: DrawingPadApp
},
{
id: 'lemonade-stand',
name: 'Lemonade Stand',
description: 'Run a virtual stand without leaving Kiddos.',
cta: 'Play Lemonade',
link: '/embedded-game',
disabled: false,
component: LemonadeStandApp
} }
]; ];

155
frontend/src/hooks/useTimeLimit.ts

@ -0,0 +1,155 @@
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());
// Update state from localStorage and cached server limit
const updateState = useCallback(() => {
setDailyLimit(getDailyLimitSync());
setTimeUsed(getTimeUsedToday());
setRemainingTime(getRemainingTimeToday());
setLimitReached(isLimitReached());
}, []);
// Fetch limit from server on mount
useEffect(() => {
getDailyLimit().then(limit => {
setDailyLimit(limit);
// Immediately recalculate limitReached with the correct server limit
updateState();
});
}, [updateState]);
// 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
};
}

7
frontend/src/pages/DrawingPadApp.tsx

@ -122,8 +122,9 @@ export function DrawingPadApp() {
}, []); }, []);
// Set canvas size on mount and resize // Set canvas size on mount and resize
useEffect(() => { const canvasRefCallback = useCallback((canvas: HTMLCanvasElement | null) => {
if (canvasRef.current) { canvasRef.current = canvas;
if (canvas) {
setupCanvas(); setupCanvas();
} }
}, [setupCanvas]); }, [setupCanvas]);
@ -222,7 +223,7 @@ export function DrawingPadApp() {
<div className="bg-white border-2 border-gray-300 rounded-lg overflow-hidden"> <div className="bg-white border-2 border-gray-300 rounded-lg overflow-hidden">
<canvas <canvas
ref={canvasRef} ref={canvasRefCallback}
className={`w-full h-[600px] touch-none ${ className={`w-full h-[600px] touch-none ${
isEraser ? 'cursor-grab' : 'cursor-crosshair' isEraser ? 'cursor-grab' : 'cursor-crosshair'
}`} }`}

70
frontend/src/pages/GameIframeApp.tsx

@ -1,70 +0,0 @@
import { useMemo, useState } from 'react';
type GameIframeAppProps = {
iframeUrl: string;
badgeText?: string;
title?: string;
description?: string;
footerNote?: string;
};
const DEFAULT_CONFIG = {
badgeText: 'Arcade Corner',
title: 'Embedded Game View',
description: 'Enjoy a game inside Kiddos while keeping all of our safety tools and controls handy.',
footerNote: 'Content below is provided by an external site.'
};
export function GameIframeApp({
iframeUrl,
badgeText,
title,
description,
footerNote
}: GameIframeAppProps) {
const [loading, setLoading] = useState(true);
const config = useMemo(
() => ({
iframeUrl,
badgeText: badgeText || DEFAULT_CONFIG.badgeText,
title: title || DEFAULT_CONFIG.title,
description: description || DEFAULT_CONFIG.description,
footerNote: footerNote || DEFAULT_CONFIG.footerNote
}),
[iframeUrl, badgeText, title, description, footerNote]
);
if (!config.iframeUrl) {
return (
<div className="min-h-screen flex items-center justify-center bg-background text-muted-foreground">
Missing iframeUrl for embedded game.
</div>
);
}
return (
<div className="min-h-screen w-full bg-background flex flex-col">
<div className="flex-1 w-full flex flex-col">
<div className="flex-1 w-full bg-card border border-border overflow-hidden flex flex-col">
{loading && (
<div className="p-4 text-center text-muted-foreground text-sm bg-muted">
Loading embedded game...
</div>
)}
<iframe
src={config.iframeUrl}
title="Embedded Game"
className="w-full h-full flex-1 border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allowFullScreen
onLoad={() => setLoading(false)}
/>
</div>
</div>
</div>
);
}

66
frontend/src/pages/LandingPage.tsx

@ -2,15 +2,13 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage'; import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage';
import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput'; import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput';
import { getAppliedMagicCode, getMagicCodeSettings, hasActiveMagicCode, refreshMagicCodeSettings } from '../services/magicCodeService'; import { getAppliedMagicCode } from '../services/magicCodeService';
import { getEnabledApps } from '../utils/appFilter'; import { getEnabledApps } from '../utils/appFilter';
const categoryEmojis: { [key: string]: string } = { const categoryEmojis: { [key: string]: string } = {
videos: '📺', videos: '📺',
speechsounds: '🗣', speechsounds: '🗣',
tictactoe: '⭕', tictactoe: '⭕',
drawingpad: '🎨',
iframegame: '🕹',
all: '🎮', all: '🎮',
}; };
@ -18,8 +16,6 @@ const categoryColors: { [key: string]: string } = {
videos: 'pink', videos: 'pink',
speechsounds: 'purple', speechsounds: 'purple',
tictactoe: 'blue', tictactoe: 'blue',
drawingpad: 'amber',
iframegame: 'green',
}; };
const colorMap: { [key: string]: string } = { const colorMap: { [key: string]: string } = {
@ -35,35 +31,11 @@ export function LandingPage() {
const [showMagicCodeModal, setShowMagicCodeModal] = useState(false); const [showMagicCodeModal, setShowMagicCodeModal] = useState(false);
const [enabledApps, setEnabledApps] = useState(getEnabledApps()); const [enabledApps, setEnabledApps] = useState(getEnabledApps());
const appliedCode = getAppliedMagicCode(); const appliedCode = getAppliedMagicCode();
const magicCodeSettings = getMagicCodeSettings();
// Refresh magic code settings from server on load (if a code exists)
useEffect(() => {
let isMounted = true;
async function syncMagicCodeSettings() {
if (!appliedCode) return;
try {
await refreshMagicCodeSettings();
if (isMounted) {
setEnabledApps(getEnabledApps());
}
} catch (error) {
console.warn('Failed to refresh magic code on load', error);
}
}
syncMagicCodeSettings();
return () => {
isMounted = false;
};
}, [appliedCode]);
// Re-check enabled apps when magic code is applied/cleared // Re-check enabled apps when magic code is applied/cleared
useEffect(() => { useEffect(() => {
setEnabledApps(getEnabledApps()); setEnabledApps(getEnabledApps());
}, [appliedCode, magicCodeSettings?.enabledApps?.join(',')]); }, [appliedCode]);
return ( return (
<div className="bg-background"> <div className="bg-background">
@ -81,7 +53,7 @@ export function LandingPage() {
<section className="px-4 py-8"> <section className="px-4 py-8">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{!hasActiveMagicCode() && ( {!appliedCode && (
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<button <button
onClick={() => setShowMagicCodeModal(true)} onClick={() => setShowMagicCodeModal(true)}
@ -134,38 +106,6 @@ export function LandingPage() {
loading="eager" loading="eager"
fetchPriority="auto" fetchPriority="auto"
/> />
) : app.id === 'drawingpad' ? (
<OptimizedImage
src="/drawing.png"
alt="Drawing Pad"
className="w-20 h-20 object-contain"
width={80}
height={80}
loading="eager"
fetchPriority="auto"
disableWebP={true}
/>
) : app.id === 'iframegame' ? (
<OptimizedImage
src="/drawing.png"
alt="Embedded Game"
className="w-20 h-20 object-contain"
width={80}
height={80}
loading="eager"
fetchPriority="auto"
/>
) : app.id === 'lemonade-stand' ? (
<OptimizedImage
src="/juice.png"
alt="Lemonade Stand"
className="w-20 h-20 object-contain"
width={80}
height={80}
loading="eager"
fetchPriority="auto"
disableWebP={true}
/>
) : ( ) : (
<span className="text-5xl">{emoji}</span> <span className="text-5xl">{emoji}</span>
)} )}

18
frontend/src/pages/LoginPage.tsx

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
export function LoginPage() { export function LoginPage() {
@ -27,7 +27,7 @@ export function LoginPage() {
}; };
return ( return (
<div className="flex items-start justify-center bg-background px-4 pt-12 pb-8"> <div className="min-h-screen flex items-center justify-center bg-background px-4 py-8">
<div className="w-full max-w-md bg-card rounded-3xl shadow-lg overflow-hidden border border-border"> <div className="w-full max-w-md bg-card rounded-3xl shadow-lg overflow-hidden border border-border">
<div className="px-8 pt-8 pb-6 text-center border-b border-border"> <div className="px-8 pt-8 pb-6 text-center border-b border-border">
<h1 className="text-2xl font-bold text-foreground mb-2">Admin Login</h1> <h1 className="text-2xl font-bold text-foreground mb-2">Admin Login</h1>
@ -75,22 +75,10 @@ export function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md mb-4" className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md"
> >
{loading ? 'Signing in...' : 'Sign In'} {loading ? 'Signing in...' : 'Sign In'}
</button> </button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<Link
to="/register"
className="text-primary hover:underline font-semibold"
>
Sign up
</Link>
</p>
</div>
</form> </form>
</div> </div>
</div> </div>

185
frontend/src/pages/RegisterPage.tsx

@ -1,185 +0,0 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [dateOfBirth, setDateOfBirth] = 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);
// Validation
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (!dateOfBirth) {
setError('Date of birth is required');
return;
}
// Validate age on frontend as well
const birthDate = new Date(dateOfBirth);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
const dayDiff = today.getDate() - birthDate.getDate();
let actualAge = age;
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
actualAge--;
}
if (actualAge < 18) {
setError('You must be at least 18 years old to register');
return;
}
setLoading(true);
try {
const { authApi } = await import('../services/apiClient');
await authApi.register(username, password, dateOfBirth);
// Registration endpoint returns tokens and user data, same as login
// Use login function to set user and token in auth context
// This ensures consistent state management
await login(username, password);
// Navigate to home page
navigate('/');
} catch (err: any) {
setError(err.error?.message || 'Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="flex items-start justify-center bg-background px-4 pt-12 pb-8">
<div className="w-full max-w-md bg-card rounded-3xl shadow-lg overflow-hidden border border-border">
<div className="px-8 pt-8 pb-6 text-center border-b border-border">
<h1 className="text-2xl font-bold text-foreground mb-2">Create Account</h1>
<p className="text-sm text-muted-foreground">Sign up to get started</p>
</div>
<form onSubmit={handleSubmit} className="px-8 py-8">
{error && (
<div className="mb-6 p-3 bg-destructive/10 text-destructive border border-destructive/20 rounded-xl text-sm">
{error}
</div>
)}
<div className="mb-5">
<label htmlFor="username" className="block mb-2 text-sm font-semibold text-foreground">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
minLength={3}
maxLength={50}
autoFocus
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">Must be at least 3 characters</p>
</div>
<div className="mb-5">
<label htmlFor="password" className="block mb-2 text-sm font-semibold text-foreground">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
required
minLength={8}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">Must be at least 8 characters</p>
</div>
<div className="mb-5">
<label htmlFor="confirmPassword" className="block mb-2 text-sm font-semibold text-foreground">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
required
minLength={8}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
</div>
<div className="mb-6">
<label htmlFor="dateOfBirth" className="block mb-2 text-sm font-semibold text-foreground">
Date of Birth
</label>
<input
id="dateOfBirth"
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
disabled={loading}
required
max={new Date(new Date().setFullYear(new Date().getFullYear() - 18)).toISOString().split('T')[0]}
className="w-full px-4 py-3 border border-border rounded-xl bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50"
/>
<p className="mt-1 text-xs text-muted-foreground">You must be at least 18 years old to register</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-xl font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-md mb-4"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Have an account?{' '}
<Link
to="/login"
className="text-primary hover:underline font-semibold"
>
Sign in here
</Link>
</p>
</div>
</form>
</div>
</div>
);
}

23
frontend/src/services/apiClient.ts

@ -6,6 +6,15 @@ const api = axios.create({
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
// Helper to create API instance without default JSON header (for FormData)
const createFormDataApi = () => {
return axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
withCredentials: true
// No default Content-Type - let axios set it automatically for FormData
});
};
let isRefreshing = false; let isRefreshing = false;
let failedQueue: any[] = []; let failedQueue: any[] = [];
@ -87,9 +96,6 @@ api.interceptors.response.use(
// Auth API // Auth API
export const authApi = { export const authApi = {
register: (username: string, password: string, dateOfBirth: string) =>
api.post('/auth/register', { username, password, dateOfBirth }),
login: (username: string, password: string) => login: (username: string, password: string) =>
api.post('/auth/login', { username, password }), api.post('/auth/login', { username, password }),
@ -127,7 +133,12 @@ export const videosApi = {
// Settings API // Settings API
export const settingsApi = { export const settingsApi = {
heartbeat: (sessionId: string, route: string, video?: { title: string; channelName: string }) => api.post('/settings/heartbeat', { sessionId, route, videoTitle: video?.title, videoChannel: video?.channelName }), getTimeLimit: () => api.get('/settings/time-limit'),
setTimeLimit: (dailyLimit: number) =>
api.put('/settings/time-limit', { dailyLimit }),
heartbeat: (sessionId: string, route: string, video?: { title: string; channelName: string }, timeLimit?: { timeUsed: number; dailyLimit: number }) => api.post('/settings/heartbeat', { sessionId, route, videoTitle: video?.title, videoChannel: video?.channelName, timeUsed: timeLimit?.timeUsed, dailyLimit: timeLimit?.dailyLimit }),
getConnectionStats: () => api.get('/settings/connection-stats') getConnectionStats: () => api.get('/settings/connection-stats')
}; };
@ -175,7 +186,7 @@ export const settingsProfilesApi = {
getById: (id: number) => api.get(`/settings-profiles/${id}`), getById: (id: number) => api.get(`/settings-profiles/${id}`),
create: (data: { name: string; description?: string; enabledApps?: string[] }) => create: (data: { name: string; description?: string; dailyTimeLimit: number; enabledApps?: string[] }) =>
api.post('/settings-profiles', data), api.post('/settings-profiles', data),
update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) => update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) =>
@ -183,7 +194,7 @@ export const settingsProfilesApi = {
delete: (id: number) => api.delete(`/settings-profiles/${id}`), delete: (id: number) => api.delete(`/settings-profiles/${id}`),
updateSettings: (id: number, settings: { enabledApps?: string[] }) => updateSettings: (id: number, settings: { dailyTimeLimit?: number; enabledApps?: string[] }) =>
api.put(`/settings-profiles/${id}/settings`, settings), api.put(`/settings-profiles/${id}/settings`, settings),
regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`) regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`)

8
frontend/src/services/connectionTracker.ts

@ -106,19 +106,23 @@ export function setCurrentVideo(video: { title: string; channelName: string } |
} }
/** /**
* Send a heartbeat to the server with the session ID, current route, and video info * Send a heartbeat to the server with the session ID, current route, video info, and time limit usage
*/ */
async function sendHeartbeat(): Promise<void> { async function sendHeartbeat(): Promise<void> {
try { try {
const { settingsApi } = await import('./apiClient'); const { settingsApi } = await import('./apiClient');
const { getTimeUsedToday, getDailyLimitSync } = await import('./timeLimitService');
const sessionId = getSessionId(); const sessionId = getSessionId();
const route = getCurrentRoute(); const route = getCurrentRoute();
const timeUsed = getTimeUsedToday();
const dailyLimit = getDailyLimitSync();
await settingsApi.heartbeat( await settingsApi.heartbeat(
sessionId, sessionId,
route, route,
currentVideo ? { title: currentVideo.title, channelName: currentVideo.channelName } : undefined currentVideo ? { title: currentVideo.title, channelName: currentVideo.channelName } : undefined,
{ timeUsed, dailyLimit }
); );
} catch (error) { } catch (error) {
// Silently fail - don't spam console with errors // Silently fail - don't spam console with errors

26
frontend/src/services/magicCodeService.ts

@ -2,6 +2,7 @@ const MAGIC_CODE_KEY = 'magic_code';
const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings'; const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings';
export interface MagicCodeSettings { export interface MagicCodeSettings {
dailyTimeLimit: number | null;
enabledApps: string[] | null; enabledApps: string[] | null;
appliedAt: string; appliedAt: string;
} }
@ -33,13 +34,6 @@ export function getMagicCodeSettings(): MagicCodeSettings | null {
return null; return null;
} }
/**
* Returns true only if both the code and its settings exist
*/
export function hasActiveMagicCode(): boolean {
return !!(getAppliedMagicCode() && getMagicCodeSettings());
}
/** /**
* Check if a magic code is currently applied * Check if a magic code is currently applied
*/ */
@ -64,6 +58,7 @@ export async function applyMagicCode(code: string): Promise<MagicCodeSettings> {
const response: any = await magicCodeApi.getSettingsByCode(normalizedCode); const response: any = await magicCodeApi.getSettingsByCode(normalizedCode);
const settings: MagicCodeSettings = { const settings: MagicCodeSettings = {
dailyTimeLimit: response.data.dailyTimeLimit,
enabledApps: response.data.enabledApps || null, enabledApps: response.data.enabledApps || null,
appliedAt: new Date().toISOString() appliedAt: new Date().toISOString()
}; };
@ -80,23 +75,6 @@ export async function applyMagicCode(code: string): Promise<MagicCodeSettings> {
return settings; return settings;
} }
/**
* Re-fetch settings for the currently applied magic code (if any)
*/
export async function refreshMagicCodeSettings(): Promise<MagicCodeSettings | null> {
const currentCode = getAppliedMagicCode();
if (!currentCode) {
return null;
}
try {
return await applyMagicCode(currentCode);
} catch (error) {
console.warn('Failed to refresh magic code settings', error);
throw error;
}
}
/** /**
* Clear the applied magic code and settings * Clear the applied magic code and settings
*/ */

210
frontend/src/services/timeLimitService.ts

@ -0,0 +1,210 @@
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 local date string in YYYY-MM-DD format (not UTC)
* This ensures the daily reset happens at local midnight, not UTC midnight
*/
function getLocalDateString(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 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 || getLocalDateString()
};
}
} catch (e) {
console.warn('Failed to parse time limit data from localStorage', e);
}
// Return default data
return {
dailyTimeUsed: 0,
lastResetDate: getLocalDateString()
};
}
/**
* 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 = getLocalDateString();
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 = getLocalDateString();
saveTimeLimitData(data);
}
}
/**
* Get current time limit configuration
* Priority: Magic code settings > Server settings > Cached > Default
*/
export async function getDailyLimit(): Promise<number> {
// Check magic code settings first (highest priority)
try {
const { getMagicCodeSettings } = await import('./magicCodeService');
const magicCodeSettings = getMagicCodeSettings();
if (magicCodeSettings && magicCodeSettings.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) {
return magicCodeSettings.dailyTimeLimit;
}
} catch (error) {
console.warn('Failed to check magic code settings:', error);
}
// 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();
const limit = response.data.dailyLimit;
cachedDailyLimit = limit;
limitCacheTime = now;
return limit;
} 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 or magic code settings)
* Note: Magic code settings are checked synchronously from localStorage
*/
export function getDailyLimitSync(): number {
// Check magic code settings first (highest priority)
// We can access localStorage synchronously
try {
const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings';
const stored = localStorage.getItem(MAGIC_CODE_SETTINGS_KEY);
if (stored) {
const magicCodeSettings = JSON.parse(stored);
if (magicCodeSettings && magicCodeSettings.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) {
return magicCodeSettings.dailyTimeLimit;
}
}
} catch (error) {
// Ignore errors in sync context
}
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 = getLocalDateString();
saveTimeLimitData(data);
}

1
frontend/src/types/api.ts

@ -50,6 +50,7 @@ export interface SettingsProfile {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isActive: boolean; isActive: boolean;
dailyTimeLimit: number | null;
enabledApps?: string[]; enabledApps?: string[];
} }

Loading…
Cancel
Save