mirror of
https://github.com/arthur-pbty/pomodoro.git
synced 2026-06-03 23:36:20 +02:00
feat: implement TodoList component with filtering and priority management
feat: add useAmbientSound hook for ambient sound management feat: create useLocalStorage hook for persistent state management feat: develop useTheme hook for theme switching functionality feat: implement useTimer hook for Pomodoro timer logic feat: create useTodos hook for managing todo list functionality style: add global styles and custom scrollbar for better UI experience chore: set up main entry point for the application feat: define types for Timer, Todo, and Statistics feat: create utility function for class name merging chore: configure Tailwind CSS for styling chore: set up TypeScript configuration for the project chore: configure Vite for development and build process
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor and OS files
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,42 @@
|
||||
# FocusPomodoro
|
||||
|
||||
Application Pomodoro moderne construite avec React, Vite et Tailwind CSS.
|
||||
|
||||
## Site en ligne
|
||||
|
||||
Lien officiel: https://pomodoro.arthurp.fr
|
||||
|
||||
Ce lien est la référence publique du projet pour le référencement et le partage.
|
||||
|
||||
## Stack technique
|
||||
|
||||
- React 19
|
||||
- Vite 7
|
||||
- TypeScript
|
||||
- Tailwind CSS 4
|
||||
- Nginx (via Docker)
|
||||
|
||||
## Lancer en local
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build de production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Déploiement Docker
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Sécurité et publication
|
||||
|
||||
- Les artefacts locaux sont exclus via `.gitignore` (`dist/`, `node_modules/`, fichiers `.env*`).
|
||||
- Le projet est prêt pour un premier push GitHub.
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
# Service de build - construit l'app puis s'arrête
|
||||
builder:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- node_modules:/app/node_modules
|
||||
command: sh -c "npm install && npm run build"
|
||||
|
||||
# Service nginx - attend que le build soit terminé
|
||||
pomodoro:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "3009:80"
|
||||
volumes:
|
||||
- ./dist:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
builder:
|
||||
condition: service_completed_successfully
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍅</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="FocusPomodoro - Une application de productivité utilisant la technique Pomodoro avec minuteur, ambiance sonore et liste de tâches." />
|
||||
<meta name="theme-color" content="#f43f5e" />
|
||||
<title>🍅 FocusPomodoro - Restez concentré</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
|
||||
# Cache pour les assets statiques
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback - redirige toutes les routes vers index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2575
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "react-vite-tailwind",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.2.4",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { Header } from './components/Header';
|
||||
import { Timer } from './components/Timer';
|
||||
import { AmbientSound } from './components/AmbientSound';
|
||||
import { TodoList } from './components/TodoList';
|
||||
import { Statistics } from './components/Statistics';
|
||||
import { Settings } from './components/Settings';
|
||||
import { useTimer } from './hooks/useTimer';
|
||||
import { useAmbientSound } from './hooks/useAmbientSound';
|
||||
import { useTodos } from './hooks/useTodos';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
|
||||
export default function App() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
mode,
|
||||
timeLeft,
|
||||
isRunning,
|
||||
progress,
|
||||
sessionCount,
|
||||
settings,
|
||||
statistics,
|
||||
changeMode,
|
||||
toggleTimer,
|
||||
resetTimer,
|
||||
updateSettings,
|
||||
skipSession,
|
||||
} = useTimer();
|
||||
|
||||
const {
|
||||
currentSound,
|
||||
volume,
|
||||
isPlaying,
|
||||
playSound,
|
||||
changeVolume,
|
||||
toggleSound,
|
||||
} = useAmbientSound();
|
||||
|
||||
const {
|
||||
todos,
|
||||
completedCount,
|
||||
activeCount,
|
||||
addTodo,
|
||||
toggleTodo,
|
||||
deleteTodo,
|
||||
clearCompleted,
|
||||
incrementPomodoro,
|
||||
} = useTodos();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-rose-50/30 to-indigo-50/30 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800 transition-colors duration-300">
|
||||
<div className="container mx-auto px-4 max-w-6xl">
|
||||
<Header
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
|
||||
<main className="py-8">
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Main Timer Section */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-3xl p-8 shadow-xl shadow-gray-200/50 dark:shadow-none border border-gray-100 dark:border-gray-700">
|
||||
<Timer
|
||||
mode={mode}
|
||||
timeLeft={timeLeft}
|
||||
isRunning={isRunning}
|
||||
progress={progress}
|
||||
sessionCount={sessionCount}
|
||||
dailyGoal={settings.dailyGoal}
|
||||
completedToday={statistics.completedToday}
|
||||
onModeChange={changeMode}
|
||||
onToggle={toggleTimer}
|
||||
onReset={resetTimer}
|
||||
onSkip={skipSession}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ambient Sound - Below Timer on Desktop */}
|
||||
<div className="mt-6">
|
||||
<AmbientSound
|
||||
currentSound={currentSound}
|
||||
volume={volume}
|
||||
isPlaying={isPlaying}
|
||||
onSoundChange={playSound}
|
||||
onVolumeChange={changeVolume}
|
||||
onToggle={toggleSound}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Statistics
|
||||
statistics={statistics}
|
||||
dailyGoal={settings.dailyGoal}
|
||||
/>
|
||||
|
||||
<TodoList
|
||||
todos={todos}
|
||||
completedCount={completedCount}
|
||||
activeCount={activeCount}
|
||||
onAdd={addTodo}
|
||||
onToggle={toggleTodo}
|
||||
onDelete={deleteTodo}
|
||||
onClearCompleted={clearCompleted}
|
||||
onIncrementPomodoro={incrementPomodoro}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips Section */}
|
||||
<div className="mt-12 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-2xl p-6 border border-gray-100 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white mb-4">
|
||||
💡 Conseils pour la technique Pomodoro
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-2xl">🎯</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-white">Restez concentré</p>
|
||||
<p>Évitez toute distraction pendant les sessions de focus</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-2xl">☕</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-white">Prenez de vraies pauses</p>
|
||||
<p>Éloignez-vous de l'écran, étirez-vous, hydratez-vous</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-2xl">📝</span>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 dark:text-white">Planifiez vos tâches</p>
|
||||
<p>Divisez les gros projets en petites tâches gérables</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="text-center py-8 text-sm text-gray-400 dark:text-gray-500">
|
||||
<p>
|
||||
Créé avec ❤️ pour améliorer votre productivité
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
© 2024 FocusPomodoro — Projet Portfolio
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Settings
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={settings}
|
||||
onUpdateSettings={updateSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { Volume2, VolumeX, CloudRain, Coffee, Radio, Trees, Waves } from 'lucide-react';
|
||||
import { SoundType } from '../hooks/useAmbientSound';
|
||||
|
||||
interface AmbientSoundProps {
|
||||
currentSound: SoundType;
|
||||
volume: number;
|
||||
isPlaying: boolean;
|
||||
onSoundChange: (sound: SoundType) => void;
|
||||
onVolumeChange: (volume: number) => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function AmbientSound({
|
||||
currentSound,
|
||||
volume,
|
||||
isPlaying,
|
||||
onSoundChange,
|
||||
onVolumeChange,
|
||||
onToggle,
|
||||
}: AmbientSoundProps) {
|
||||
const sounds: { key: SoundType; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'none', label: 'Aucun', icon: <VolumeX className="w-5 h-5" /> },
|
||||
{ key: 'rain', label: 'Pluie', icon: <CloudRain className="w-5 h-5" /> },
|
||||
{ key: 'cafe', label: 'Café', icon: <Coffee className="w-5 h-5" /> },
|
||||
{ key: 'whitenoise', label: 'Bruit blanc', icon: <Radio className="w-5 h-5" /> },
|
||||
{ key: 'forest', label: 'Forêt', icon: <Trees className="w-5 h-5" /> },
|
||||
{ key: 'ocean', label: 'Océan', icon: <Waves className="w-5 h-5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Volume2 className="w-5 h-5 text-indigo-500" />
|
||||
Ambiance sonore
|
||||
</h3>
|
||||
{currentSound !== 'none' && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-all ${
|
||||
isPlaying
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? '🔊 En cours' : '🔇 Muet'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sound selector */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{sounds.map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSoundChange(key)}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-xl transition-all ${
|
||||
currentSound === key
|
||||
? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400 ring-2 ring-indigo-500'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Volume slider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<VolumeX className="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4
|
||||
[&::-webkit-slider-thumb]:bg-indigo-500 [&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:transition-transform
|
||||
[&::-webkit-slider-thumb]:hover:scale-110"
|
||||
disabled={currentSound === 'none'}
|
||||
/>
|
||||
<Volume2 className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 w-12 text-right">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { TimerMode } from '../types';
|
||||
|
||||
interface CircularProgressProps {
|
||||
progress: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
mode: TimerMode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CircularProgress({
|
||||
progress,
|
||||
size = 280,
|
||||
strokeWidth = 8,
|
||||
mode,
|
||||
children
|
||||
}: CircularProgressProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (progress / 100) * circumference;
|
||||
|
||||
const getColor = () => {
|
||||
switch (mode) {
|
||||
case 'focus': return 'stroke-rose-500 dark:stroke-rose-400';
|
||||
case 'shortBreak': return 'stroke-emerald-500 dark:stroke-emerald-400';
|
||||
case 'longBreak': return 'stroke-indigo-500 dark:stroke-indigo-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
switch (mode) {
|
||||
case 'focus': return 'stroke-rose-100 dark:stroke-rose-900/30';
|
||||
case 'shortBreak': return 'stroke-emerald-100 dark:stroke-emerald-900/30';
|
||||
case 'longBreak': return 'stroke-indigo-100 dark:stroke-indigo-900/30';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className={getBgColor()}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
className={`${getColor()} transition-all duration-300 ease-out`}
|
||||
style={{
|
||||
strokeDasharray: circumference,
|
||||
strokeDashoffset: offset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Settings, Moon, Sun, Timer } from 'lucide-react';
|
||||
import { Theme } from '../hooks/useTheme';
|
||||
|
||||
interface HeaderProps {
|
||||
theme: Theme;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function Header({ theme, onToggleTheme, onOpenSettings }: HeaderProps) {
|
||||
return (
|
||||
<header className="flex items-center justify-between py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-rose-500 to-rose-600 rounded-xl flex items-center justify-center shadow-lg shadow-rose-500/25">
|
||||
<Timer className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white">
|
||||
FocusPomodoro
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Restez concentré, soyez productif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="p-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all"
|
||||
title={theme === 'light' ? 'Mode sombre' : 'Mode clair'}
|
||||
>
|
||||
{theme === 'light' ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-3 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all"
|
||||
title="Paramètres"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Settings as SettingsIcon, X, Clock, Target, Zap } from 'lucide-react';
|
||||
import { TimerSettings } from '../types';
|
||||
|
||||
interface SettingsProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: TimerSettings;
|
||||
onUpdateSettings: (settings: Partial<TimerSettings>) => void;
|
||||
}
|
||||
|
||||
export function Settings({ isOpen, onClose, settings, onUpdateSettings }: SettingsProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<SettingsIcon className="w-5 h-5 text-indigo-500" />
|
||||
Paramètres
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Timer durations */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-rose-500" />
|
||||
Durées (en minutes)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
🍅 Focus
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.focusDuration}
|
||||
onChange={(e) => onUpdateSettings({ focusDuration: parseInt(e.target.value) || 25 })}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
☕ Pause courte
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
value={settings.shortBreakDuration}
|
||||
onChange={(e) => onUpdateSettings({ shortBreakDuration: parseInt(e.target.value) || 5 })}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
🌴 Pause longue
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={settings.longBreakDuration}
|
||||
onChange={(e) => onUpdateSettings({ longBreakDuration: parseInt(e.target.value) || 15 })}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily goal */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-emerald-500" />
|
||||
Objectif quotidien
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="16"
|
||||
value={settings.dailyGoal}
|
||||
onChange={(e) => onUpdateSettings({ dailyGoal: parseInt(e.target.value) })}
|
||||
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4
|
||||
[&::-webkit-slider-thumb]:bg-emerald-500 [&::-webkit-slider-thumb]:rounded-full"
|
||||
/>
|
||||
<span className="w-16 text-center font-medium text-gray-800 dark:text-white">
|
||||
{settings.dailyGoal} 🍅
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-start options */}
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-amber-500" />
|
||||
Automatisation
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-xl cursor-pointer">
|
||||
<span className="text-gray-700 dark:text-gray-300">Démarrer les pauses automatiquement</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoStartBreaks}
|
||||
onChange={(e) => onUpdateSettings({ autoStartBreaks: e.target.checked })}
|
||||
className="w-5 h-5 rounded text-rose-500 focus:ring-rose-500 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-xl cursor-pointer">
|
||||
<span className="text-gray-700 dark:text-gray-300">Démarrer les pomodoros automatiquement</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoStartPomodoros}
|
||||
onChange={(e) => onUpdateSettings({ autoStartPomodoros: e.target.checked })}
|
||||
className="w-5 h-5 rounded text-rose-500 focus:ring-rose-500 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full py-3 bg-indigo-500 hover:bg-indigo-600 text-white font-medium rounded-xl transition-all"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { TrendingUp, Flame, Target, Award } from 'lucide-react';
|
||||
import { Statistics as StatsType } from '../types';
|
||||
|
||||
interface StatisticsProps {
|
||||
statistics: StatsType;
|
||||
dailyGoal: number;
|
||||
}
|
||||
|
||||
export function Statistics({ statistics, dailyGoal }: StatisticsProps) {
|
||||
const goalProgress = Math.min((statistics.completedToday / dailyGoal) * 100, 100);
|
||||
const goalReached = statistics.completedToday >= dailyGoal;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Aujourd\'hui',
|
||||
value: statistics.completedToday,
|
||||
icon: Target,
|
||||
color: 'text-rose-500',
|
||||
bgColor: 'bg-rose-50 dark:bg-rose-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Total',
|
||||
value: statistics.completedTotal,
|
||||
icon: TrendingUp,
|
||||
color: 'text-indigo-500',
|
||||
bgColor: 'bg-indigo-50 dark:bg-indigo-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Série actuelle',
|
||||
value: `${statistics.currentStreak} jour${statistics.currentStreak > 1 ? 's' : ''}`,
|
||||
icon: Flame,
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Meilleure série',
|
||||
value: `${statistics.bestStreak} jour${statistics.bestStreak > 1 ? 's' : ''}`,
|
||||
icon: Award,
|
||||
color: 'text-amber-500',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/30',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-indigo-500" />
|
||||
Statistiques
|
||||
</h3>
|
||||
{goalReached && (
|
||||
<span className="text-xs px-2 py-1 bg-emerald-100 dark:bg-emerald-900/50 text-emerald-600 dark:text-emerald-400 rounded-full flex items-center gap-1">
|
||||
<Award className="w-3 h-3" />
|
||||
Objectif atteint !
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Goal progress */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">Progression quotidienne</span>
|
||||
<span className="font-medium text-gray-800 dark:text-white">
|
||||
{statistics.completedToday}/{dailyGoal} 🍅
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
goalReached
|
||||
? 'bg-gradient-to-r from-emerald-400 to-emerald-500'
|
||||
: 'bg-gradient-to-r from-rose-400 to-rose-500'
|
||||
}`}
|
||||
style={{ width: `${goalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{stats.map(({ label, value, icon: Icon, color, bgColor }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`${bgColor} rounded-xl p-4 transition-transform hover:scale-105`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${color} mb-2`} />
|
||||
<div className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Play, Pause, RotateCcw, SkipForward } from 'lucide-react';
|
||||
import { TimerMode } from '../types';
|
||||
import { CircularProgress } from './CircularProgress';
|
||||
|
||||
interface TimerProps {
|
||||
mode: TimerMode;
|
||||
timeLeft: number;
|
||||
isRunning: boolean;
|
||||
progress: number;
|
||||
sessionCount: number;
|
||||
dailyGoal: number;
|
||||
completedToday: number;
|
||||
onModeChange: (mode: TimerMode) => void;
|
||||
onToggle: () => void;
|
||||
onReset: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function Timer({
|
||||
mode,
|
||||
timeLeft,
|
||||
isRunning,
|
||||
progress,
|
||||
sessionCount,
|
||||
dailyGoal,
|
||||
completedToday,
|
||||
onModeChange,
|
||||
onToggle,
|
||||
onReset,
|
||||
onSkip,
|
||||
}: TimerProps) {
|
||||
const minutes = Math.floor(timeLeft / 60);
|
||||
const seconds = timeLeft % 60;
|
||||
|
||||
const modes: { key: TimerMode; label: string; emoji: string }[] = [
|
||||
{ key: 'focus', label: 'Focus', emoji: '🍅' },
|
||||
{ key: 'shortBreak', label: 'Pause courte', emoji: '☕' },
|
||||
{ key: 'longBreak', label: 'Pause longue', emoji: '🌴' },
|
||||
];
|
||||
|
||||
const getModeStyles = (key: TimerMode, isActive: boolean) => {
|
||||
const base = 'px-4 py-2 rounded-full text-sm font-medium transition-all duration-200';
|
||||
if (!isActive) return `${base} text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700`;
|
||||
|
||||
switch (key) {
|
||||
case 'focus': return `${base} bg-rose-500 text-white shadow-lg shadow-rose-500/25`;
|
||||
case 'shortBreak': return `${base} bg-emerald-500 text-white shadow-lg shadow-emerald-500/25`;
|
||||
case 'longBreak': return `${base} bg-indigo-500 text-white shadow-lg shadow-indigo-500/25`;
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonStyles = () => {
|
||||
switch (mode) {
|
||||
case 'focus': return 'bg-rose-500 hover:bg-rose-600 shadow-lg shadow-rose-500/30';
|
||||
case 'shortBreak': return 'bg-emerald-500 hover:bg-emerald-600 shadow-lg shadow-emerald-500/30';
|
||||
case 'longBreak': return 'bg-indigo-500 hover:bg-indigo-600 shadow-lg shadow-indigo-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Mode selector */}
|
||||
<div className="flex gap-2 mb-8 p-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||||
{modes.map(({ key, label, emoji }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onModeChange(key)}
|
||||
className={getModeStyles(key, mode === key)}
|
||||
>
|
||||
<span className="mr-1">{emoji}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Circular Timer */}
|
||||
<CircularProgress progress={progress} mode={mode} size={300} strokeWidth={10}>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl md:text-7xl font-bold font-mono text-gray-800 dark:text-white tracking-tight">
|
||||
{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Session {(sessionCount % 4) + 1}/4
|
||||
</div>
|
||||
</div>
|
||||
</CircularProgress>
|
||||
|
||||
{/* Daily progress */}
|
||||
<div className="mt-6 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Objectif du jour :</span>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: dailyGoal }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-3 h-3 rounded-full transition-all ${
|
||||
i < completedToday
|
||||
? 'bg-rose-500 scale-110'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-medium">{completedToday}/{dailyGoal}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="p-3 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||
title="Réinitialiser (R)"
|
||||
>
|
||||
<RotateCcw className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`p-5 rounded-full text-white transition-all transform hover:scale-105 active:scale-95 ${getButtonStyles()}`}
|
||||
title={isRunning ? 'Pause (Espace)' : 'Démarrer (Espace)'}
|
||||
>
|
||||
{isRunning ? <Pause className="w-8 h-8" /> : <Play className="w-8 h-8 ml-1" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="p-3 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
|
||||
title="Passer"
|
||||
>
|
||||
<SkipForward className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<div className="mt-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
<kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded">Espace</kbd> Play/Pause •
|
||||
<kbd className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded ml-2">R</kbd> Reset
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Check, Trash2, ListTodo, Sparkles } from 'lucide-react';
|
||||
import { Todo, Priority } from '../types';
|
||||
|
||||
interface TodoListProps {
|
||||
todos: Todo[];
|
||||
completedCount: number;
|
||||
activeCount: number;
|
||||
onAdd: (text: string, priority: Priority) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onClearCompleted: () => void;
|
||||
onIncrementPomodoro: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TodoList({
|
||||
todos,
|
||||
completedCount,
|
||||
activeCount,
|
||||
onAdd,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onClearCompleted,
|
||||
onIncrementPomodoro,
|
||||
}: TodoListProps) {
|
||||
const [newTodo, setNewTodo] = useState('');
|
||||
const [priority, setPriority] = useState<Priority>('medium');
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newTodo.trim()) {
|
||||
onAdd(newTodo, priority);
|
||||
setNewTodo('');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTodos = todos.filter(todo => {
|
||||
if (filter === 'active') return !todo.completed;
|
||||
if (filter === 'completed') return todo.completed;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getPriorityColor = (p: Priority) => {
|
||||
switch (p) {
|
||||
case 'high': return 'text-red-500 bg-red-50 dark:bg-red-900/30';
|
||||
case 'medium': return 'text-amber-500 bg-amber-50 dark:bg-amber-900/30';
|
||||
case 'low': return 'text-blue-500 bg-blue-50 dark:bg-blue-900/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (p: Priority) => {
|
||||
switch (p) {
|
||||
case 'high': return 'Haute';
|
||||
case 'medium': return 'Moyenne';
|
||||
case 'low': return 'Basse';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-sm border border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<ListTodo className="w-5 h-5 text-rose-500" />
|
||||
Tâches
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-full">
|
||||
{activeCount} active{activeCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add todo form */}
|
||||
<form onSubmit={handleSubmit} className="mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTodo}
|
||||
onChange={(e) => setNewTodo(e.target.value)}
|
||||
placeholder="Ajouter une tâche..."
|
||||
className="flex-1 min-w-0 px-4 py-2.5 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-800 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as Priority)}
|
||||
className="flex-1 sm:flex-none px-3 py-2.5 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||
>
|
||||
<option value="high">🔴 Haute</option>
|
||||
<option value="medium">🟡 Moyenne</option>
|
||||
<option value="low">🔵 Basse</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-shrink-0 p-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-xl transition-all hover:scale-105 active:scale-95 shadow-lg shadow-rose-500/25"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 mb-4 p-1 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
{(['all', 'active', 'completed'] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-all ${
|
||||
filter === f
|
||||
? 'bg-white dark:bg-gray-600 text-gray-800 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Toutes' : f === 'active' ? 'Actives' : 'Terminées'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-1">
|
||||
{filteredTodos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-gray-500">
|
||||
<Sparkles className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{filter === 'all'
|
||||
? 'Aucune tâche pour le moment'
|
||||
: filter === 'active'
|
||||
? 'Toutes les tâches sont terminées !'
|
||||
: 'Aucune tâche terminée'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTodos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`group flex items-center gap-3 p-3 rounded-xl transition-all ${
|
||||
todo.completed
|
||||
? 'bg-gray-50 dark:bg-gray-700/50'
|
||||
: 'bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => onToggle(todo.id)}
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all ${
|
||||
todo.completed
|
||||
? 'bg-emerald-500 border-emerald-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-500 hover:border-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{todo.completed && <Check className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-gray-800 dark:text-white truncate ${
|
||||
todo.completed ? 'line-through text-gray-400 dark:text-gray-500' : ''
|
||||
}`}>
|
||||
{todo.text}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getPriorityColor(todo.priority)}`}>
|
||||
{getPriorityLabel(todo.priority)}
|
||||
</span>
|
||||
{todo.pomodorosSpent > 0 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
🍅 {todo.pomodorosSpent}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!todo.completed && (
|
||||
<button
|
||||
onClick={() => onIncrementPomodoro(todo.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-rose-500 rounded-lg hover:bg-rose-50 dark:hover:bg-rose-900/30 transition-all"
|
||||
title="Ajouter un pomodoro"
|
||||
>
|
||||
🍅
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(todo.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear completed */}
|
||||
{completedCount > 0 && (
|
||||
<button
|
||||
onClick={onClearCompleted}
|
||||
className="mt-4 w-full py-2 text-sm text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
Supprimer les {completedCount} tâche{completedCount > 1 ? 's' : ''} terminée{completedCount > 1 ? 's' : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
|
||||
export type SoundType = 'none' | 'rain' | 'cafe' | 'whitenoise' | 'forest' | 'ocean';
|
||||
|
||||
export function useAmbientSound() {
|
||||
const [currentSound, setCurrentSound] = useState<SoundType>('none');
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const nodesRef = useRef<{
|
||||
sources: AudioBufferSourceNode[];
|
||||
gains: GainNode[];
|
||||
masterGain: GainNode | null;
|
||||
}>({ sources: [], gains: [], masterGain: null });
|
||||
|
||||
const stopSound = useCallback(() => {
|
||||
nodesRef.current.sources.forEach(source => {
|
||||
try { source.stop(); } catch (e) {}
|
||||
});
|
||||
nodesRef.current.sources = [];
|
||||
nodesRef.current.gains = [];
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const createNoiseBuffer = useCallback((ctx: AudioContext, type: 'white' | 'brown' | 'pink') => {
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const output = buffer.getChannelData(0);
|
||||
|
||||
if (type === 'white') {
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
output[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
} else if (type === 'brown') {
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
output[i] = (lastOut + 0.02 * white) / 1.02;
|
||||
lastOut = output[i];
|
||||
output[i] *= 3.5;
|
||||
}
|
||||
} else if (type === 'pink') {
|
||||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||||
b2 = 0.96900 * b2 + white * 0.1538520;
|
||||
b3 = 0.86650 * b3 + white * 0.3104856;
|
||||
b4 = 0.55000 * b4 + white * 0.5329522;
|
||||
b5 = -0.7616 * b5 - white * 0.0168980;
|
||||
output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||||
output[i] *= 0.11;
|
||||
b6 = white * 0.115926;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}, []);
|
||||
|
||||
const playSound = useCallback((type: SoundType) => {
|
||||
stopSound();
|
||||
|
||||
if (type === 'none') {
|
||||
setCurrentSound('none');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
}
|
||||
const ctx = audioContextRef.current;
|
||||
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.gain.value = volume;
|
||||
masterGain.connect(ctx.destination);
|
||||
nodesRef.current.masterGain = masterGain;
|
||||
|
||||
const createLoopingSource = (buffer: AudioBuffer, gainValue: number = 1) => {
|
||||
const source = ctx.createBufferSource();
|
||||
const gain = ctx.createGain();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
gain.gain.value = gainValue;
|
||||
source.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
source.start();
|
||||
nodesRef.current.sources.push(source);
|
||||
nodesRef.current.gains.push(gain);
|
||||
return { source, gain };
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'rain': {
|
||||
// Bruit de pluie = bruit rose + bruit brun
|
||||
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
|
||||
const brownBuffer = createNoiseBuffer(ctx, 'brown');
|
||||
createLoopingSource(pinkBuffer, 0.6);
|
||||
createLoopingSource(brownBuffer, 0.3);
|
||||
break;
|
||||
}
|
||||
case 'cafe': {
|
||||
// Ambiance café = bruit brun léger + variations
|
||||
const brownBuffer = createNoiseBuffer(ctx, 'brown');
|
||||
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
|
||||
createLoopingSource(brownBuffer, 0.4);
|
||||
createLoopingSource(pinkBuffer, 0.15);
|
||||
break;
|
||||
}
|
||||
case 'whitenoise': {
|
||||
const whiteBuffer = createNoiseBuffer(ctx, 'white');
|
||||
createLoopingSource(whiteBuffer, 0.3);
|
||||
break;
|
||||
}
|
||||
case 'forest': {
|
||||
// Forêt = bruit rose doux
|
||||
const pinkBuffer = createNoiseBuffer(ctx, 'pink');
|
||||
const brownBuffer = createNoiseBuffer(ctx, 'brown');
|
||||
createLoopingSource(pinkBuffer, 0.25);
|
||||
createLoopingSource(brownBuffer, 0.15);
|
||||
break;
|
||||
}
|
||||
case 'ocean': {
|
||||
// Océan = bruit brun
|
||||
const brownBuffer = createNoiseBuffer(ctx, 'brown');
|
||||
createLoopingSource(brownBuffer, 0.7);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentSound(type);
|
||||
setIsPlaying(true);
|
||||
}, [volume, stopSound, createNoiseBuffer]);
|
||||
|
||||
const changeVolume = useCallback((newVolume: number) => {
|
||||
setVolume(newVolume);
|
||||
if (nodesRef.current.masterGain) {
|
||||
nodesRef.current.masterGain.gain.value = newVolume;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSound = useCallback(() => {
|
||||
if (isPlaying) {
|
||||
stopSound();
|
||||
} else if (currentSound !== 'none') {
|
||||
playSound(currentSound);
|
||||
}
|
||||
}, [isPlaying, currentSound, playSound, stopSound]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopSound();
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [stopSound]);
|
||||
|
||||
return {
|
||||
currentSound,
|
||||
volume,
|
||||
isPlaying,
|
||||
playSound,
|
||||
stopSound,
|
||||
changeVolume,
|
||||
toggleSound,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
}, [key, storedValue]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useLocalStorage<Theme>('pomodoro-theme', 'light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const body = window.document.body;
|
||||
|
||||
// Remove previous theme classes
|
||||
root.classList.remove('light', 'dark');
|
||||
body.classList.remove('light', 'dark');
|
||||
|
||||
// Add current theme class
|
||||
root.classList.add(theme);
|
||||
body.classList.add(theme);
|
||||
|
||||
// Update background color immediately
|
||||
if (theme === 'dark') {
|
||||
root.style.colorScheme = 'dark';
|
||||
body.style.backgroundColor = '#111827';
|
||||
} else {
|
||||
root.style.colorScheme = 'light';
|
||||
body.style.backgroundColor = '#fdf2f8';
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||
}, [setTheme]);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { TimerMode, TimerSettings, Statistics, DEFAULT_SETTINGS, DEFAULT_STATISTICS } from '../types';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
export function useTimer() {
|
||||
const [settings, setSettings] = useLocalStorage<TimerSettings>('pomodoro-settings', DEFAULT_SETTINGS);
|
||||
const [statistics, setStatistics] = useLocalStorage<Statistics>('pomodoro-statistics', DEFAULT_STATISTICS);
|
||||
|
||||
const [mode, setMode] = useState<TimerMode>('focus');
|
||||
const [timeLeft, setTimeLeft] = useState(settings.focusDuration * 60);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [sessionCount, setSessionCount] = useState(0);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const totalTime = useRef(settings.focusDuration * 60);
|
||||
|
||||
// Refs pour le timer basé sur le temps réel
|
||||
const endTimeRef = useRef<number | null>(null);
|
||||
const timerCompletedWhileHiddenRef = useRef(false);
|
||||
const titleBlinkIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getDuration = useCallback((timerMode: TimerMode) => {
|
||||
switch (timerMode) {
|
||||
case 'focus': return settings.focusDuration * 60;
|
||||
case 'shortBreak': return settings.shortBreakDuration * 60;
|
||||
case 'longBreak': return settings.longBreakDuration * 60;
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const progress = ((totalTime.current - timeLeft) / totalTime.current) * 100;
|
||||
|
||||
const playNotificationSound = useCallback(() => {
|
||||
try {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new AudioContext();
|
||||
}
|
||||
const ctx = audioContextRef.current;
|
||||
|
||||
// Jouer une mélodie agréable
|
||||
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = freq;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
const startTime = ctx.currentTime + i * 0.15;
|
||||
gainNode.gain.setValueAtTime(0.3, startTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + 0.3);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Could not play notification sound:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestNotificationPermission = useCallback(async () => {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showNotification = useCallback((title: string, body: string) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, {
|
||||
body,
|
||||
icon: '🍅',
|
||||
requireInteraction: true, // La notification reste jusqu'à ce que l'utilisateur clique
|
||||
tag: 'pomodoro-timer' // Évite les notifications en double
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fonction pour faire clignoter le titre
|
||||
const startTitleBlink = useCallback((message: string) => {
|
||||
if (titleBlinkIntervalRef.current) return; // Déjà en train de clignoter
|
||||
|
||||
const originalTitle = document.title;
|
||||
let isOriginal = true;
|
||||
|
||||
titleBlinkIntervalRef.current = setInterval(() => {
|
||||
document.title = isOriginal ? `🔔 ${message}` : originalTitle;
|
||||
isOriginal = !isOriginal;
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const stopTitleBlink = useCallback(() => {
|
||||
if (titleBlinkIntervalRef.current) {
|
||||
clearInterval(titleBlinkIntervalRef.current);
|
||||
titleBlinkIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateStatistics = useCallback(() => {
|
||||
const today = new Date().toDateString();
|
||||
|
||||
setStatistics(prev => {
|
||||
const isNewDay = prev.lastCompletedDate !== today;
|
||||
const newCompletedToday = isNewDay ? 1 : prev.completedToday + 1;
|
||||
|
||||
// Calcul de la série
|
||||
let newStreak = prev.currentStreak;
|
||||
if (isNewDay) {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (prev.lastCompletedDate === yesterday.toDateString()) {
|
||||
newStreak = prev.currentStreak + 1;
|
||||
} else if (prev.lastCompletedDate !== today) {
|
||||
newStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
completedToday: newCompletedToday,
|
||||
completedTotal: prev.completedTotal + 1,
|
||||
currentStreak: newStreak,
|
||||
bestStreak: Math.max(prev.bestStreak, newStreak),
|
||||
lastCompletedDate: today,
|
||||
};
|
||||
});
|
||||
}, [setStatistics]);
|
||||
|
||||
const handleTimerComplete = useCallback(() => {
|
||||
const isPageHidden = document.visibilityState === 'hidden';
|
||||
|
||||
// Si la page est visible, jouer le son immédiatement
|
||||
if (!isPageHidden) {
|
||||
playNotificationSound();
|
||||
} else {
|
||||
// Sinon, marquer qu'on doit jouer le son quand l'utilisateur revient
|
||||
timerCompletedWhileHiddenRef.current = true;
|
||||
}
|
||||
|
||||
setIsRunning(false);
|
||||
endTimeRef.current = null;
|
||||
|
||||
// Faire clignoter le titre si la page est cachée
|
||||
if (isPageHidden) {
|
||||
startTitleBlink(mode === 'focus' ? 'Pomodoro terminé !' : 'Pause terminée !');
|
||||
}
|
||||
|
||||
if (mode === 'focus') {
|
||||
updateStatistics();
|
||||
const newSessionCount = sessionCount + 1;
|
||||
setSessionCount(newSessionCount);
|
||||
|
||||
showNotification('🍅 Pomodoro terminé !', 'Bravo ! Il est temps de faire une pause.');
|
||||
|
||||
// Après 4 pomodoros, pause longue
|
||||
if (newSessionCount % 4 === 0) {
|
||||
setMode('longBreak');
|
||||
const duration = getDuration('longBreak');
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
if (settings.autoStartBreaks) {
|
||||
setIsRunning(true);
|
||||
endTimeRef.current = Date.now() + duration * 1000;
|
||||
}
|
||||
} else {
|
||||
setMode('shortBreak');
|
||||
const duration = getDuration('shortBreak');
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
if (settings.autoStartBreaks) {
|
||||
setIsRunning(true);
|
||||
endTimeRef.current = Date.now() + duration * 1000;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showNotification('⏰ Pause terminée !', 'C\'est reparti pour un nouveau pomodoro !');
|
||||
setMode('focus');
|
||||
const duration = getDuration('focus');
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
if (settings.autoStartPomodoros) {
|
||||
setIsRunning(true);
|
||||
endTimeRef.current = Date.now() + duration * 1000;
|
||||
}
|
||||
}
|
||||
}, [mode, sessionCount, settings, getDuration, playNotificationSound, showNotification, updateStatistics, startTitleBlink]);
|
||||
|
||||
// Timer basé sur le temps réel (fonctionne même en arrière-plan)
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (isRunning) {
|
||||
// Si on démarre et qu'il n'y a pas d'heure de fin, la définir
|
||||
if (endTimeRef.current === null) {
|
||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
if (endTimeRef.current === null) return;
|
||||
|
||||
const remaining = Math.round((endTimeRef.current - Date.now()) / 1000);
|
||||
|
||||
if (remaining <= 0) {
|
||||
setTimeLeft(0);
|
||||
handleTimerComplete();
|
||||
} else {
|
||||
setTimeLeft(remaining);
|
||||
}
|
||||
};
|
||||
|
||||
// Mise à jour toutes les 100ms pour plus de précision
|
||||
interval = setInterval(updateTimer, 100);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [isRunning, handleTimerComplete]);
|
||||
|
||||
// Gestionnaire de visibilité - jouer le son et arrêter le clignotement quand l'utilisateur revient
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// L'utilisateur est revenu sur l'onglet
|
||||
stopTitleBlink();
|
||||
|
||||
// Si le timer s'est terminé pendant son absence, jouer le son
|
||||
if (timerCompletedWhileHiddenRef.current) {
|
||||
timerCompletedWhileHiddenRef.current = false;
|
||||
playNotificationSound();
|
||||
}
|
||||
|
||||
// Recalculer le temps restant si le timer est en cours
|
||||
if (isRunning && endTimeRef.current !== null) {
|
||||
const remaining = Math.round((endTimeRef.current - Date.now()) / 1000);
|
||||
if (remaining <= 0) {
|
||||
setTimeLeft(0);
|
||||
handleTimerComplete();
|
||||
} else {
|
||||
setTimeLeft(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [isRunning, handleTimerComplete, playNotificationSound, stopTitleBlink]);
|
||||
|
||||
// Mise à jour du titre de l'onglet
|
||||
useEffect(() => {
|
||||
const minutes = Math.floor(timeLeft / 60);
|
||||
const seconds = timeLeft % 60;
|
||||
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
const modeLabels = { focus: 'Focus', shortBreak: 'Pause', longBreak: 'Pause longue' };
|
||||
const emoji = mode === 'focus' ? '🍅' : '☕';
|
||||
document.title = `${emoji} ${timeString} - ${modeLabels[mode]} | FocusPomodoro`;
|
||||
}, [timeLeft, mode]);
|
||||
|
||||
// Raccourcis clavier
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
|
||||
switch (e.code) {
|
||||
case 'Space':
|
||||
e.preventDefault();
|
||||
if (!isRunning) {
|
||||
// Démarrage
|
||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||
} else {
|
||||
// Pause
|
||||
endTimeRef.current = null;
|
||||
}
|
||||
setIsRunning(prev => !prev);
|
||||
break;
|
||||
case 'KeyR':
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const duration = getDuration(mode);
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
setIsRunning(false);
|
||||
endTimeRef.current = null;
|
||||
stopTitleBlink();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [mode, getDuration, isRunning, timeLeft, stopTitleBlink]);
|
||||
|
||||
const changeMode = useCallback((newMode: TimerMode) => {
|
||||
setMode(newMode);
|
||||
const duration = getDuration(newMode);
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
setIsRunning(false);
|
||||
endTimeRef.current = null;
|
||||
stopTitleBlink();
|
||||
}, [getDuration, stopTitleBlink]);
|
||||
|
||||
const toggleTimer = useCallback(() => {
|
||||
if (!isRunning) {
|
||||
requestNotificationPermission();
|
||||
// Démarrage : définir l'heure de fin
|
||||
endTimeRef.current = Date.now() + timeLeft * 1000;
|
||||
} else {
|
||||
// Pause : effacer l'heure de fin
|
||||
endTimeRef.current = null;
|
||||
}
|
||||
setIsRunning(prev => !prev);
|
||||
}, [isRunning, requestNotificationPermission, timeLeft]);
|
||||
|
||||
const resetTimer = useCallback(() => {
|
||||
const duration = getDuration(mode);
|
||||
setTimeLeft(duration);
|
||||
totalTime.current = duration;
|
||||
setIsRunning(false);
|
||||
endTimeRef.current = null;
|
||||
stopTitleBlink();
|
||||
}, [mode, getDuration, stopTitleBlink]);
|
||||
|
||||
const updateSettings = useCallback((newSettings: Partial<TimerSettings>) => {
|
||||
setSettings(prev => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
// Si on modifie la durée du mode actuel, on met à jour le timer
|
||||
if (!isRunning) {
|
||||
let newDuration = timeLeft;
|
||||
if (mode === 'focus' && newSettings.focusDuration) {
|
||||
newDuration = newSettings.focusDuration * 60;
|
||||
} else if (mode === 'shortBreak' && newSettings.shortBreakDuration) {
|
||||
newDuration = newSettings.shortBreakDuration * 60;
|
||||
} else if (mode === 'longBreak' && newSettings.longBreakDuration) {
|
||||
newDuration = newSettings.longBreakDuration * 60;
|
||||
}
|
||||
setTimeLeft(newDuration);
|
||||
totalTime.current = newDuration;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, [setSettings, isRunning, mode, timeLeft]);
|
||||
|
||||
const skipSession = useCallback(() => {
|
||||
handleTimerComplete();
|
||||
}, [handleTimerComplete]);
|
||||
|
||||
return {
|
||||
mode,
|
||||
timeLeft,
|
||||
isRunning,
|
||||
progress,
|
||||
sessionCount,
|
||||
settings,
|
||||
statistics,
|
||||
changeMode,
|
||||
toggleTimer,
|
||||
resetTimer,
|
||||
updateSettings,
|
||||
skipSession,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Todo, Priority } from '../types';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
export function useTodos() {
|
||||
const [todos, setTodos] = useLocalStorage<Todo[]>('pomodoro-todos', []);
|
||||
|
||||
const addTodo = useCallback((text: string, priority: Priority = 'medium') => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
const newTodo: Todo = {
|
||||
id: Date.now().toString(),
|
||||
text: text.trim(),
|
||||
completed: false,
|
||||
priority,
|
||||
pomodorosSpent: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
setTodos(prev => [newTodo, ...prev]);
|
||||
}, [setTodos]);
|
||||
|
||||
const toggleTodo = useCallback((id: string) => {
|
||||
setTodos(prev => prev.map(todo =>
|
||||
todo.id === id ? { ...todo, completed: !todo.completed } : todo
|
||||
));
|
||||
}, [setTodos]);
|
||||
|
||||
const deleteTodo = useCallback((id: string) => {
|
||||
setTodos(prev => prev.filter(todo => todo.id !== id));
|
||||
}, [setTodos]);
|
||||
|
||||
const updateTodo = useCallback((id: string, updates: Partial<Todo>) => {
|
||||
setTodos(prev => prev.map(todo =>
|
||||
todo.id === id ? { ...todo, ...updates } : todo
|
||||
));
|
||||
}, [setTodos]);
|
||||
|
||||
const incrementPomodoro = useCallback((id: string) => {
|
||||
setTodos(prev => prev.map(todo =>
|
||||
todo.id === id ? { ...todo, pomodorosSpent: todo.pomodorosSpent + 1 } : todo
|
||||
));
|
||||
}, [setTodos]);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setTodos(prev => prev.filter(todo => !todo.completed));
|
||||
}, [setTodos]);
|
||||
|
||||
const reorderTodos = useCallback((fromIndex: number, toIndex: number) => {
|
||||
setTodos(prev => {
|
||||
const result = [...prev];
|
||||
const [removed] = result.splice(fromIndex, 1);
|
||||
result.splice(toIndex, 0, removed);
|
||||
return result;
|
||||
});
|
||||
}, [setTodos]);
|
||||
|
||||
const completedCount = todos.filter(t => t.completed).length;
|
||||
const activeCount = todos.filter(t => !t.completed).length;
|
||||
|
||||
return {
|
||||
todos,
|
||||
addTodo,
|
||||
toggleTodo,
|
||||
deleteTodo,
|
||||
updateTodo,
|
||||
incrementPomodoro,
|
||||
clearCompleted,
|
||||
reorderTodos,
|
||||
completedCount,
|
||||
activeCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
html {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
export type TimerMode = 'focus' | 'shortBreak' | 'longBreak';
|
||||
|
||||
export type Priority = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface Todo {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
priority: Priority;
|
||||
pomodorosSpent: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface TimerSettings {
|
||||
focusDuration: number;
|
||||
shortBreakDuration: number;
|
||||
longBreakDuration: number;
|
||||
autoStartBreaks: boolean;
|
||||
autoStartPomodoros: boolean;
|
||||
dailyGoal: number;
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
completedToday: number;
|
||||
completedTotal: number;
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
lastCompletedDate: string | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: TimerSettings = {
|
||||
focusDuration: 25,
|
||||
shortBreakDuration: 5,
|
||||
longBreakDuration: 15,
|
||||
autoStartBreaks: false,
|
||||
autoStartPomodoros: false,
|
||||
dailyGoal: 8,
|
||||
};
|
||||
|
||||
export const DEFAULT_STATISTICS: Statistics = {
|
||||
completedToday: 0,
|
||||
completedTotal: 0,
|
||||
currentStreak: 0,
|
||||
bestStreak: 0,
|
||||
lastCompletedDate: null,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), viteSingleFile()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user