Compare commits

..

12 Commits

  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. 387
      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,9 +1,3 @@
# 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 } from '../services/auth.service.js'; import { createTokens, refreshAccessToken, revokeRefreshToken, verifyPassword, hashPassword } 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,6 +150,134 @@ 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,11 +49,8 @@ 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 numeric values // Parse JSON array for enabled_apps
if (key === 'daily_time_limit_minutes') { if (key === 'enabled_apps') {
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) {
@ -75,7 +72,6 @@ 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,63 +4,6 @@ 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
@ -85,23 +28,19 @@ export async function heartbeat(req: AuthRequest, res: Response) {
}); });
} }
// Get route, video info, and time limit usage from request body // Get route and video info 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, video info, and time limit usage) // Register heartbeat (with user info if authenticated, current route, and video info)
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,9 +73,6 @@ 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
}; };
}); });
@ -168,9 +165,6 @@ 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
} }
}); });
@ -198,7 +192,7 @@ export async function createProfile(req: AuthRequest, res: Response) {
}); });
} }
const { name, description, dailyTimeLimit, enabledApps } = req.body; const { name, description, 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({
@ -210,16 +204,6 @@ 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();
@ -234,15 +218,6 @@ 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({
@ -269,8 +244,7 @@ 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) {
@ -487,7 +461,7 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) {
}); });
} }
const { dailyTimeLimit, enabledApps } = req.body; const { enabledApps } = req.body;
// Verify ownership // Verify ownership
const existing = await db.execute({ const existing = await db.execute({
@ -505,30 +479,6 @@ 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({
@ -553,24 +503,9 @@ 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,6 +6,30 @@ 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,11 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import { login, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js'; import { login, register, refresh, logout, getCurrentUser } from '../controllers/auth.controller.js';
import { authMiddleware } from '../middleware/auth.js'; import { authMiddleware } from '../middleware/auth.js';
import { validateRequest, loginSchema } from '../middleware/validation.js'; import { validateRequest, loginSchema, registerSchema } 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,17 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { getTimeLimit, setTimeLimit, heartbeat, getConnectionStats } from '../controllers/settings.controller.js'; import { 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,8 +10,6 @@ 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;
} }
@ -31,7 +29,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, timeUsed?: number, dailyLimit?: number): void { heartbeat(sessionId: string, userId?: number, username?: string, route?: string, videoTitle?: string, videoChannel?: string): void {
const now = Date.now(); const now = Date.now();
const existing = this.connections.get(sessionId); const existing = this.connections.get(sessionId);
@ -43,8 +41,6 @@ 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, {
@ -54,8 +50,6 @@ 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.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
frontend/public/juice.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

2
frontend/src/App.tsx

@ -19,6 +19,7 @@ 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 = () => (
@ -67,6 +68,7 @@ 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-16"> <footer className="bg-muted border-t border-border mt-auto">
<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,13 +64,6 @@ 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">
@ -85,11 +78,6 @@ 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"

387
frontend/src/components/Navbar/Navbar.tsx

@ -6,196 +6,199 @@ import { APPS } from '../../config/apps';
import { OptimizedImage } from '../OptimizedImage/OptimizedImage'; import { OptimizedImage } from '../OptimizedImage/OptimizedImage';
export function Navbar() { export function Navbar() {
const { isAuthenticated, logout, isAdmin } = useAuth(); const { isAuthenticated, logout, isAdmin } = useAuth();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { channels } = useChannels(); const { channels } = useChannels();
// Detect current app from registry // Detect current app from registry
const getCurrentApp = (pathname: string) => { const getCurrentApp = (pathname: string) => {
return APPS.find(app => pathname === app.link || pathname.startsWith(app.link + '/')); return APPS.find(app => pathname === app.link || pathname.startsWith(app.link + '/'));
}; };
const currentApp = getCurrentApp(location.pathname); const currentApp = getCurrentApp(location.pathname);
const isVideoApp = currentApp?.id === 'videos'; const isVideoApp = currentApp?.id === 'videos';
const [searchInput, setSearchInput] = useState(searchParams.get('search') || ''); const [searchInput, setSearchInput] = useState(searchParams.get('search') || '');
// Sync search input with URL params // Sync search input with URL params
useEffect(() => { useEffect(() => {
setSearchInput(searchParams.get('search') || ''); setSearchInput(searchParams.get('search') || '');
}, [searchParams]); }, [searchParams]);
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
}; };
const handleSearchSubmit = (e: React.FormEvent) => { const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
if (searchInput) { if (searchInput) {
newParams.set('search', searchInput); newParams.set('search', searchInput);
} else { } else {
newParams.delete('search'); newParams.delete('search');
} }
newParams.set('page', '1'); newParams.set('page', '1');
setSearchParams(newParams); setSearchParams(newParams);
}; };
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set('sort', e.target.value); newParams.set('sort', e.target.value);
newParams.set('page', '1'); newParams.set('page', '1');
setSearchParams(newParams); setSearchParams(newParams);
}; };
const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleChannelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
if (e.target.value) { if (e.target.value) {
newParams.set('channel', e.target.value); newParams.set('channel', e.target.value);
} else { } else {
newParams.delete('channel'); newParams.delete('channel');
} }
newParams.set('page', '1'); newParams.set('page', '1');
setSearchParams(newParams); setSearchParams(newParams);
}; };
const handleClearFilters = () => { const handleClearFilters = () => {
setSearchInput(''); setSearchInput('');
setSearchParams(new URLSearchParams()); setSearchParams(new URLSearchParams());
}; };
const hasFilters = searchParams.get('search') || searchParams.get('channel') || const hasFilters = searchParams.get('search') || searchParams.get('channel') ||
(searchParams.get('sort') && searchParams.get('sort') !== 'newest'); (searchParams.get('sort') && searchParams.get('sort') !== 'newest');
return ( return (
<> <>
<header className="bg-white border-b-4 border-primary sticky top-0 z-50"> <header className="bg-white border-b-4 border-primary sticky top-0 z-50">
<div className="max-w-5xl mx-auto px-4 py-5"> <div className="max-w-5xl mx-auto px-4 py-5">
<div className="flex items-center gap-3 justify-between"> <div className="flex items-center gap-3 justify-between">
<Link to="/" className="flex items-center gap-3"> <Link to="/" className="flex items-center gap-3">
<OptimizedImage <OptimizedImage
src="/rainbow.png" src="/rainbow.png"
alt="Rainbow" alt="Rainbow"
className="h-10 w-10 md:h-12 md:w-12 object-contain" className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48} width={48}
height={48} height={48}
loading="eager" loading="eager"
fetchPriority="high" fetchPriority="high"
/> />
<h1 className="text-3xl md:text-4xl font-bold text-foreground" style={{ fontFamily: "'Butterfly Kids', cursive" }}>Rainbows, Cupcakes & Unicorns</h1> <h1 className="text-3xl md:text-4xl font-bold text-foreground" style={{ fontFamily: "'Butterfly Kids', cursive" }}>Rainbows, Cupcakes & Unicorns</h1>
<OptimizedImage <OptimizedImage
src="/cupcake.png" src="/cupcake.png"
alt="Cupcake" alt="Cupcake"
className="h-10 w-10 md:h-12 md:w-12 object-contain" className="h-10 w-10 md:h-12 md:w-12 object-contain"
width={48} width={48}
height={48} height={48}
loading="eager" loading="eager"
fetchPriority="high" fetchPriority="high"
/> />
</Link> </Link>
<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 ${ className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/'
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' }`}
}`} >
> Home
Home </Link>
</Link>
{!isAuthenticated && (
{isAdmin && ( <Link
<Link to="/register"
to="/admin" className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname === '/register'
className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${ ? 'bg-primary text-primary-foreground shadow-md'
location.pathname.startsWith('/admin') : 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
? 'bg-primary text-primary-foreground shadow-md' }`}
: 'bg-white text-foreground border-2 border-primary hover:bg-pink-50' >
}`} Sign In / Register
> </Link>
Admin )}
</Link>
)} {isAdmin && (
<Link
{isAuthenticated ? ( to="/admin"
<button className={`text-sm font-semibold px-3 py-2 rounded-full transition-all active:scale-95 ${location.pathname.startsWith('/admin')
onClick={handleLogout} ? 'bg-primary text-primary-foreground 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" : 'bg-white text-foreground border-2 border-primary hover:bg-pink-50'
> }`}
Logout >
</button> Admin
) : ( </Link>
<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" {isAuthenticated && (
> <button
Login onClick={handleLogout}
</Link> 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"
)} >
</div> Logout
</div> </button>
</div> )}
</header> </div>
</div>
{isVideoApp && ( </div>
<div className="bg-muted border-b border-border"> </header>
<div className="max-w-5xl mx-auto px-4 py-4">
<div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center"> {isVideoApp && (
<form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 max-w-md"> <div className="bg-muted border-b border-border">
<input <div className="max-w-5xl mx-auto px-4 py-4">
type="text" <div className="flex flex-col sm:flex-row gap-4 items-stretch sm:items-center">
placeholder="Search videos..." <form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 max-w-md">
value={searchInput} <input
onChange={(e) => setSearchInput(e.target.value)} type="text"
className="flex-1 px-4 py-2 border border-border rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="Search videos..."
/> value={searchInput}
<button onChange={(e) => setSearchInput(e.target.value)}
type="submit" className="flex-1 px-4 py-2 border border-border rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
className="px-4 py-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors text-sm font-semibold" />
> <button
🔍 type="submit"
</button> className="px-4 py-2 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors text-sm font-semibold"
</form> >
🔍
<div className="flex gap-2 flex-wrap sm:flex-nowrap"> </button>
<select </form>
value={searchParams.get('sort') || 'newest'}
onChange={handleSortChange} <div className="flex gap-2 flex-wrap sm:flex-nowrap">
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" <select
> value={searchParams.get('sort') || 'newest'}
<option value="newest">Newest</option> onChange={handleSortChange}
<option value="oldest">Oldest</option> className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
<option value="popular">Most Popular</option> >
</select> <option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<select <option value="popular">Most Popular</option>
value={searchParams.get('channel') || ''} </select>
onChange={handleChannelChange}
className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" <select
> value={searchParams.get('channel') || ''}
<option value="">All Channels</option> onChange={handleChannelChange}
{channels.map(channel => ( className="px-4 py-2 border border-border rounded-full bg-white text-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
<option key={channel.id} value={channel.id}> >
{channel.name} <option value="">All Channels</option>
</option> {channels.map(channel => (
))} <option key={channel.id} value={channel.id}>
</select> {channel.name}
</option>
{hasFilters && ( ))}
<button </select>
onClick={handleClearFilters}
className="px-4 py-2 border border-border rounded-full bg-white text-sm hover:bg-muted transition-colors whitespace-nowrap" {hasFilters && (
> <button
Clear Filters onClick={handleClearFilters}
</button> className="px-4 py-2 border border-border rounded-full bg-white text-sm hover:bg-muted transition-colors whitespace-nowrap"
)} >
</div> Clear Filters
</div> </button>
</div> )}
</div> </div>
)} </div>
</> </div>
); </div>
)}
</>
);
} }

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

@ -1,215 +0,0 @@
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,6 +5,16 @@ 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;
@ -52,5 +62,14 @@ 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

@ -1,155 +0,0 @@
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,9 +122,8 @@ export function DrawingPadApp() {
}, []); }, []);
// Set canvas size on mount and resize // Set canvas size on mount and resize
const canvasRefCallback = useCallback((canvas: HTMLCanvasElement | null) => { useEffect(() => {
canvasRef.current = canvas; if (canvasRef.current) {
if (canvas) {
setupCanvas(); setupCanvas();
} }
}, [setupCanvas]); }, [setupCanvas]);
@ -223,7 +222,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={canvasRefCallback} ref={canvasRef}
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

@ -0,0 +1,70 @@
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,13 +2,15 @@ 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 } from '../services/magicCodeService'; import { getAppliedMagicCode, getMagicCodeSettings, hasActiveMagicCode, refreshMagicCodeSettings } 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: '🎮',
}; };
@ -16,6 +18,8 @@ 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 } = {
@ -31,11 +35,35 @@ 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]); }, [appliedCode, magicCodeSettings?.enabledApps?.join(',')]);
return ( return (
<div className="bg-background"> <div className="bg-background">
@ -53,7 +81,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">
{!appliedCode && ( {!hasActiveMagicCode() && (
<div className="mb-6 text-center"> <div className="mb-6 text-center">
<button <button
onClick={() => setShowMagicCodeModal(true)} onClick={() => setShowMagicCodeModal(true)}
@ -106,6 +134,38 @@ 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 } from 'react-router-dom'; import { useNavigate, Link } 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="min-h-screen flex items-center justify-center bg-background px-4 py-8"> <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="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,10 +75,22 @@ 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" 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 ? '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

@ -0,0 +1,185 @@
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,15 +6,6 @@ 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[] = [];
@ -96,6 +87,9 @@ 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 }),
@ -133,12 +127,7 @@ export const videosApi = {
// Settings API // Settings API
export const settingsApi = { export const settingsApi = {
getTimeLimit: () => api.get('/settings/time-limit'), heartbeat: (sessionId: string, route: string, video?: { title: string; channelName: string }) => api.post('/settings/heartbeat', { sessionId, route, videoTitle: video?.title, videoChannel: video?.channelName }),
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')
}; };
@ -186,7 +175,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; dailyTimeLimit: number; enabledApps?: string[] }) => create: (data: { name: string; description?: string; 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 }) =>
@ -194,7 +183,7 @@ export const settingsProfilesApi = {
delete: (id: number) => api.delete(`/settings-profiles/${id}`), delete: (id: number) => api.delete(`/settings-profiles/${id}`),
updateSettings: (id: number, settings: { dailyTimeLimit?: number; enabledApps?: string[] }) => updateSettings: (id: number, settings: { 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,23 +106,19 @@ export function setCurrentVideo(video: { title: string; channelName: string } |
} }
/** /**
* Send a heartbeat to the server with the session ID, current route, video info, and time limit usage * Send a heartbeat to the server with the session ID, current route, and video info
*/ */
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,7 +2,6 @@ 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;
} }
@ -34,6 +33,13 @@ 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
*/ */
@ -58,7 +64,6 @@ 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()
}; };
@ -75,6 +80,23 @@ 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

@ -1,210 +0,0 @@
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,7 +50,6 @@ 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