Add feature to help users concentrate by reducing distractions

Implement Focus Mode with customizable timers, ambient sounds, and distraction blocking.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: c5f0c281-8dd8-4846-b452-4a07bcd21062
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9777c70b-fc38-4831-8d6b-78dfffe041b0/78d8bd21-4c82-4da0-9a7c-bd83af57beb4.jpg
This commit is contained in:
ghaddaditw
2025-06-08 07:34:46 +00:00
parent d001be31e9
commit ff04093f21
2 changed files with 514 additions and 0 deletions
@@ -0,0 +1,40 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Target } from "lucide-react";
import { useTranslation } from "react-i18next";
import FocusMode from "./FocusMode";
interface FocusButtonProps {
variant?: "default" | "outline" | "secondary" | "ghost";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
}
export default function FocusButton({
variant = "outline",
size = "default",
className = ""
}: FocusButtonProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
variant={variant}
size={size}
onClick={() => setIsOpen(true)}
className={`gap-2 ${className}`}
data-tour="focus-mode"
>
<Target className="h-4 w-4" />
{size !== "icon" && t('focus.enterFocus', 'Focus Mode')}
</Button>
<FocusMode
isOpen={isOpen}
onClose={() => setIsOpen(false)}
/>
</>
);
}
+474
View File
@@ -0,0 +1,474 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Play,
Pause,
Square,
Volume2,
VolumeX,
Eye,
EyeOff,
Timer,
Coffee,
Target,
Zap
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface FocusSession {
id: string;
duration: number;
completed: boolean;
startTime: Date;
endTime?: Date;
type: 'work' | 'break';
}
interface FocusModeProps {
isOpen: boolean;
onClose: () => void;
}
const FOCUS_DURATIONS = {
pomodoro: { work: 25, shortBreak: 5, longBreak: 15 },
custom: { work: 30, shortBreak: 10, longBreak: 20 },
deep: { work: 90, shortBreak: 20, longBreak: 30 }
};
const AMBIENT_SOUNDS = [
{ id: 'rain', name: 'Rain', file: '/sounds/rain.mp3' },
{ id: 'forest', name: 'Forest', file: '/sounds/forest.mp3' },
{ id: 'cafe', name: 'Café', file: '/sounds/cafe.mp3' },
{ id: 'whitenoise', name: 'White Noise', file: '/sounds/whitenoise.mp3' },
{ id: 'ocean', name: 'Ocean Waves', file: '/sounds/ocean.mp3' }
];
export default function FocusMode({ isOpen, onClose }: FocusModeProps) {
const { t } = useTranslation();
const [isActive, setIsActive] = useState(false);
const [timeLeft, setTimeLeft] = useState(25 * 60); // 25 minutes in seconds
const [currentSession, setCurrentSession] = useState<'work' | 'break'>('work');
const [sessionCount, setSessionCount] = useState(0);
const [focusType, setFocusType] = useState<keyof typeof FOCUS_DURATIONS>('pomodoro');
const [ambientSound, setAmbientSound] = useState<string>('');
const [ambientVolume, setAmbientVolume] = useState([50]);
const [blockDistractions, setBlockDistractions] = useState(true);
const [showNotifications, setShowNotifications] = useState(true);
const [sessions, setSessions] = useState<FocusSession[]>([]);
const intervalRef = useRef<NodeJS.Timeout>();
const audioRef = useRef<HTMLAudioElement | null>(null);
const startTimeRef = useRef<Date>();
useEffect(() => {
if (isActive && timeLeft > 0) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
handleSessionComplete();
return 0;
}
return prev - 1;
});
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isActive, timeLeft]);
useEffect(() => {
if (ambientSound && audioRef.current) {
audioRef.current.volume = ambientVolume[0] / 100;
if (isActive) {
audioRef.current.play().catch(console.error);
} else {
audioRef.current.pause();
}
}
}, [ambientSound, ambientVolume, isActive]);
useEffect(() => {
// Apply focus mode styles when active
if (isActive && blockDistractions) {
document.body.classList.add('focus-mode-active');
// Hide distracting elements
const distractingElements = document.querySelectorAll('.floating-voice-control, .notification-toast');
distractingElements.forEach(el => {
(el as HTMLElement).style.display = 'none';
});
} else {
document.body.classList.remove('focus-mode-active');
// Restore distracting elements
const distractingElements = document.querySelectorAll('.floating-voice-control, .notification-toast');
distractingElements.forEach(el => {
(el as HTMLElement).style.display = '';
});
}
return () => {
document.body.classList.remove('focus-mode-active');
const distractingElements = document.querySelectorAll('.floating-voice-control, .notification-toast');
distractingElements.forEach(el => {
(el as HTMLElement).style.display = '';
});
};
}, [isActive, blockDistractions]);
const handleSessionComplete = () => {
const session: FocusSession = {
id: Date.now().toString(),
duration: getDuration(currentSession),
completed: true,
startTime: startTimeRef.current || new Date(),
endTime: new Date(),
type: currentSession
};
setSessions(prev => [...prev, session]);
if (showNotifications && 'Notification' in window) {
new Notification(
currentSession === 'work'
? t('focus.sessionComplete', 'Work session complete!')
: t('focus.breakComplete', 'Break time over!')
);
}
// Auto-switch between work and break
if (currentSession === 'work') {
setSessionCount(prev => prev + 1);
const isLongBreak = (sessionCount + 1) % 4 === 0;
setCurrentSession('break');
setTimeLeft(getDuration(isLongBreak ? 'longBreak' : 'shortBreak'));
} else {
setCurrentSession('work');
setTimeLeft(getDuration('work'));
}
setIsActive(false);
};
const getDuration = (type: 'work' | 'shortBreak' | 'longBreak' | 'break') => {
if (type === 'break') type = 'shortBreak';
const durations = FOCUS_DURATIONS[focusType];
return durations[type] * 60; // Convert to seconds
};
const startSession = () => {
setIsActive(true);
startTimeRef.current = new Date();
// Request notification permission
if (showNotifications && 'Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
};
const pauseSession = () => {
setIsActive(false);
};
const stopSession = () => {
setIsActive(false);
setTimeLeft(getDuration('work'));
setCurrentSession('work');
setSessionCount(0);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const getProgress = () => {
const totalDuration = getDuration(currentSession);
return ((totalDuration - timeLeft) / totalDuration) * 100;
};
const getTodaysStats = () => {
const today = new Date().toDateString();
const todaySessions = sessions.filter(s => s.startTime.toDateString() === today);
const workSessions = todaySessions.filter(s => s.type === 'work' && s.completed);
const totalTime = workSessions.reduce((acc, s) => acc + s.duration, 0);
return {
sessions: workSessions.length,
totalTime: Math.round(totalTime / 60), // Convert to minutes
streak: getStreak()
};
};
const getStreak = () => {
// Calculate consecutive days with at least one completed work session
let streak = 0;
const today = new Date();
for (let i = 0; i < 30; i++) {
const checkDate = new Date(today);
checkDate.setDate(today.getDate() - i);
const dateString = checkDate.toDateString();
const hasSession = sessions.some(s =>
s.startTime.toDateString() === dateString &&
s.type === 'work' &&
s.completed
);
if (hasSession) {
streak++;
} else if (i > 0) {
break;
}
}
return streak;
};
const stats = getTodaysStats();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-50">
<div className="container mx-auto p-6 h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Target className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">{t('focus.title', 'Focus Mode')}</h1>
</div>
<Button variant="outline" onClick={onClose}>
{blockDistractions && isActive ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
{t('focus.exit', 'Exit Focus')}
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1">
{/* Main Timer */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
{currentSession === 'work' ? (
<>
<Zap className="h-5 w-5 text-orange-500" />
{t('focus.workSession', 'Work Session')}
</>
) : (
<>
<Coffee className="h-5 w-5 text-green-500" />
{t('focus.breakTime', 'Break Time')}
</>
)}
<Badge variant="secondary">
{t('focus.session', 'Session')} {sessionCount + 1}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-8">
{/* Timer Display */}
<div className="text-8xl font-mono font-bold text-center">
{formatTime(timeLeft)}
</div>
{/* Progress Bar */}
<div className="w-full max-w-md">
<Progress value={getProgress()} className="h-3" />
</div>
{/* Controls */}
<div className="flex gap-4">
{!isActive ? (
<Button onClick={startSession} size="lg" className="gap-2">
<Play className="h-5 w-5" />
{t('focus.start', 'Start')}
</Button>
) : (
<Button onClick={pauseSession} size="lg" variant="secondary" className="gap-2">
<Pause className="h-5 w-5" />
{t('focus.pause', 'Pause')}
</Button>
)}
<Button onClick={stopSession} size="lg" variant="outline" className="gap-2">
<Square className="h-5 w-5" />
{t('focus.stop', 'Stop')}
</Button>
</div>
{/* Session Type Selector */}
<div className="w-full max-w-md space-y-4">
<Label>{t('focus.focusType', 'Focus Type')}</Label>
<Select value={focusType} onValueChange={(value) => {
setFocusType(value as keyof typeof FOCUS_DURATIONS);
if (!isActive) {
setTimeLeft(getDuration('work'));
}
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pomodoro">
{t('focus.pomodoro', 'Pomodoro')} (25/5/15)
</SelectItem>
<SelectItem value="custom">
{t('focus.custom', 'Custom')} (30/10/20)
</SelectItem>
<SelectItem value="deep">
{t('focus.deepWork', 'Deep Work')} (90/20/30)
</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
{/* Settings & Stats */}
<div className="space-y-6">
{/* Today's Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
{t('focus.todaysStats', "Today's Stats")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary">{stats.sessions}</div>
<div className="text-sm text-muted-foreground">{t('focus.sessions', 'Sessions')}</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{stats.totalTime}m</div>
<div className="text-sm text-muted-foreground">{t('focus.focused', 'Focused')}</div>
</div>
<div>
<div className="text-2xl font-bold text-orange-600">{stats.streak}</div>
<div className="text-sm text-muted-foreground">{t('focus.streak', 'Streak')}</div>
</div>
</div>
</CardContent>
</Card>
{/* Settings */}
<Card>
<CardHeader>
<CardTitle>{t('focus.settings', 'Settings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Block Distractions */}
<div className="flex items-center justify-between">
<Label htmlFor="block-distractions">
{t('focus.blockDistractions', 'Block Distractions')}
</Label>
<Switch
id="block-distractions"
checked={blockDistractions}
onCheckedChange={setBlockDistractions}
/>
</div>
{/* Notifications */}
<div className="flex items-center justify-between">
<Label htmlFor="notifications">
{t('focus.notifications', 'Notifications')}
</Label>
<Switch
id="notifications"
checked={showNotifications}
onCheckedChange={setShowNotifications}
/>
</div>
{/* Ambient Sound */}
<div className="space-y-3">
<Label>{t('focus.ambientSound', 'Ambient Sound')}</Label>
<Select value={ambientSound} onValueChange={setAmbientSound}>
<SelectTrigger>
<SelectValue placeholder={t('focus.selectSound', 'Select sound')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">
{t('focus.noSound', 'No Sound')}
</SelectItem>
{AMBIENT_SOUNDS.map(sound => (
<SelectItem key={sound.id} value={sound.id}>
{sound.name}
</SelectItem>
))}
</SelectContent>
</Select>
{ambientSound && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<VolumeX className="h-4 w-4" />
<Volume2 className="h-4 w-4" />
</div>
<Slider
value={ambientVolume}
onValueChange={setAmbientVolume}
max={100}
step={5}
className="w-full"
/>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Audio Element */}
{ambientSound && (
<audio
ref={audioRef}
loop
src={AMBIENT_SOUNDS.find(s => s.id === ambientSound)?.file}
/>
)}
{/* Focus Mode Styles */}
<style jsx global>{`
.focus-mode-active {
--blur-intensity: 0;
}
.focus-mode-active .floating-voice-control,
.focus-mode-active .notification-toast,
.focus-mode-active .sidebar-distractions {
opacity: 0.3;
pointer-events: none;
transition: opacity 0.3s ease;
}
.focus-mode-active .main-content {
filter: blur(var(--blur-intensity, 0px));
transition: filter 0.3s ease;
}
`}</style>
</div>
);
}