13 changed files with 16 additions and 764 deletions
@ -1,215 +0,0 @@
@@ -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> |
||||
); |
||||
} |
||||
@ -1,155 +0,0 @@
@@ -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 |
||||
}; |
||||
} |
||||
@ -1,210 +0,0 @@
@@ -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); |
||||
} |
||||
Loading…
Reference in new issue