diff --git a/migrations/003_add_due_date_to_todos.sql b/migrations/003_add_due_date_to_todos.sql new file mode 100644 index 0000000..3c67484 --- /dev/null +++ b/migrations/003_add_due_date_to_todos.sql @@ -0,0 +1,11 @@ +-- Migration: Add due_date to note_todos table +-- Description: Adds a due_date column to track task deadlines + +-- Add due_date column to note_todos table +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ; + +-- Create index on due_date for faster queries +CREATE INDEX IF NOT EXISTS idx_note_todos_due_date ON note_todos(due_date); + +-- Create index on combination of completed and due_date for efficient queries of pending tasks +CREATE INDEX IF NOT EXISTS idx_note_todos_completed_due_date ON note_todos(completed, due_date); diff --git a/migrations/004_add_due_date_to_global_todos.sql b/migrations/004_add_due_date_to_global_todos.sql new file mode 100644 index 0000000..4d79eb4 --- /dev/null +++ b/migrations/004_add_due_date_to_global_todos.sql @@ -0,0 +1,11 @@ +-- Migration: Add due_date to global_todos table +-- Description: Adds a due_date column to track task deadlines for global todos + +-- Add due_date column to global_todos table +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ; + +-- Create index on due_date for faster queries +CREATE INDEX IF NOT EXISTS idx_global_todos_due_date ON global_todos(due_date); + +-- Create index on combination of completed and due_date for efficient queries of pending tasks +CREATE INDEX IF NOT EXISTS idx_global_todos_completed_due_date ON global_todos(completed, due_date); diff --git a/migrations/005_add_subtasks_support.sql b/migrations/005_add_subtasks_support.sql new file mode 100644 index 0000000..8727a30 --- /dev/null +++ b/migrations/005_add_subtasks_support.sql @@ -0,0 +1,16 @@ +-- Migration: Add subtasks support to todos tables +-- Description: Adds parent_id column to enable hierarchical task structure + +-- Add parent_id column to note_todos table (note todos) +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE; + +-- Add parent_id column to global_todos table +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE; + +-- Create indexes for faster subtask queries +CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id); +CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id); + +-- Add level column to track depth (optional, for UI optimization) +ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0; +ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0; diff --git a/routes/notes.routes.js b/routes/notes.routes.js index 14650e3..f78dbc7 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -61,7 +61,7 @@ router.get('/', async (req, res) => { // Charger les todos, images, fichiers et tags pour chaque note for (const note of notes) { const todos = await getAll(` - SELECT id, text, completed, position + SELECT id, text, completed, position, parent_id, level FROM note_todos WHERE note_id = $1 ORDER BY position ASC, id ASC @@ -391,10 +391,13 @@ router.delete('/:id/image', async (req, res) => { /** * POST /api/notes/:id/todos - * Ajouter un todo à une note + * Ajouter un todo à une note (supports subtasks via parent_id) */ router.post('/:id/todos', - [body('text').trim().notEmpty().withMessage('Le texte est requis')], + [ + body('text').trim().notEmpty().withMessage('Le texte est requis'), + body('parent_id').optional().isInt() + ], async (req, res) => { try { const errors = validationResult(req); @@ -408,17 +411,27 @@ router.post('/:id/todos', return res.status(404).json({ error: 'Note non trouvée' }); } - const { text } = req.body; + const { text, parent_id } = req.body; + let level = 0; - logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"`); + // Si c'est un subtask, vérifier que le parent existe et calculer le niveau + if (parent_id) { + const parent = await getOne('SELECT id, level FROM note_todos WHERE id = $1 AND note_id = $2', [parent_id, req.params.id]); + if (!parent) { + return res.status(404).json({ error: 'Todo parent non trouvé' }); + } + level = (parent.level || 0) + 1; + } + + logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`); const result = await runQuery(` - INSERT INTO note_todos (note_id, text, position) - VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $3)) + INSERT INTO note_todos (note_id, text, parent_id, level, position) + VALUES ($1, $2, $3, $4, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $5 AND COALESCE(parent_id, 0) = COALESCE($6, 0))) RETURNING * - `, [req.params.id, text, req.params.id]); + `, [req.params.id, text, parent_id || null, level, req.params.id, parent_id || null]); - logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}`); + logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}, level: ${level}`); await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); @@ -426,7 +439,9 @@ router.post('/:id/todos', id: result.id, text, completed: false, - position: 0 + position: result.position || 0, + parent_id: parent_id || null, + level: level }); } catch (error) { logger.error('Erreur lors de l\'ajout du todo:', error); diff --git a/routes/todos.routes.js b/routes/todos.routes.js index 7b86d8e..297be55 100644 --- a/routes/todos.routes.js +++ b/routes/todos.routes.js @@ -19,7 +19,7 @@ router.get('/', async (req, res) => { // Essayer d'abord avec le champ priority (nouvelle version) try { const todos = await getAll(` - SELECT id, text, completed, priority, in_progress, created_at + SELECT id, text, completed, priority, in_progress, parent_id, level, created_at FROM global_todos WHERE user_id = $1 ORDER BY priority DESC, created_at DESC @@ -38,7 +38,7 @@ router.get('/', async (req, res) => { `, [req.user.id]); // Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend - const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false })); + const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false, parent_id: null, level: 0 })); res.json(todosWithPriority); } } catch (error) { @@ -49,10 +49,13 @@ router.get('/', async (req, res) => { /** * POST /api/todos - * Créer un nouveau todo global + * Créer un nouveau todo global (supports subtasks via parent_id) */ router.post('/', - [body('text').trim().notEmpty().withMessage('Le texte est requis')], + [ + body('text').trim().notEmpty().withMessage('Le texte est requis'), + body('parent_id').optional().isInt() + ], async (req, res) => { try { const errors = validationResult(req); @@ -60,17 +63,27 @@ router.post('/', return res.status(400).json({ error: 'Données invalides', details: errors.array() }); } - const { text } = req.body; + const { text, parent_id } = req.body; + let level = 0; - logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"`); + // Si c'est un subtask, vérifier que le parent existe et calculer le niveau + if (parent_id) { + const parent = await getOne('SELECT id, level FROM global_todos WHERE id = $1 AND user_id = $2', [parent_id, req.user.id]); + if (!parent) { + return res.status(404).json({ error: 'Todo parent non trouvé' }); + } + level = (parent.level || 0) + 1; + } + + logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`); const result = await runQuery(` - INSERT INTO global_todos (user_id, text) - VALUES ($1, $2) + INSERT INTO global_todos (user_id, text, parent_id, level) + VALUES ($1, $2, $3, $4) RETURNING * - `, [req.user.id, text]); + `, [req.user.id, text, parent_id || null, level]); - logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}`); + logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}, level: ${level}`); res.status(201).json({ id: result.id, @@ -78,6 +91,8 @@ router.post('/', completed: false, priority: false, in_progress: false, + parent_id: parent_id || null, + level: level, created_at: new Date().toISOString() }); } catch (error) { diff --git a/src/App.tsx b/src/App.tsx index 42ed1da..06ed6d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ const App = () => { return ( - + diff --git a/src/components/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx new file mode 100644 index 0000000..34b365a --- /dev/null +++ b/src/components/AdvancedSearch.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Calendar } from "@/components/ui/calendar"; +import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; + +export interface SearchFilters { + tags: string[]; // Tag names + dateFrom?: Date; + dateTo?: Date; + hasTodos?: boolean; + hasImages?: boolean; + hasFiles?: boolean; + priority?: boolean; +} + +export interface TagOption { + id: number; + name: string; +} + +interface AdvancedSearchProps { + filters: SearchFilters; + onFiltersChange: (filters: SearchFilters) => void; + availableTags: TagOption[]; +} + +export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) { + const [open, setOpen] = useState(false); + const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null); + const [tagSelectorOpen, setTagSelectorOpen] = useState(false); + + const hasActiveFilters = filters.tags.length > 0 || + filters.dateFrom !== undefined || + filters.dateTo !== undefined || + filters.hasTodos !== undefined || + filters.hasImages !== undefined || + filters.hasFiles !== undefined || + filters.priority !== undefined; + + const clearFilters = () => { + onFiltersChange({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); + }; + + const toggleTag = (tagName: string) => { + const newTags = filters.tags.includes(tagName) + ? filters.tags.filter(name => name !== tagName) + : [...filters.tags, tagName]; + onFiltersChange({ ...filters, tags: newTags }); + }; + + const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name)); + + return ( +
+ + + + + +
+
+

Filtres avancés

+ {hasActiveFilters && ( + + )} +
+ + {/* Tags Filter */} +
+ + + + + + + + + + Aucun tag trouvé + + {availableTags.map((tag) => ( + toggleTag(tag.name)} + className="cursor-pointer" + > + + + {tag.name} + + ))} + + + + + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag.name} + toggleTag(tag.name)} + /> + + ))} +
+ )} +
+ + {/* Date Range Filter */} +
+ +
+ setDatePickerOpen(open ? 'from' : null)}> + + + + + { + onFiltersChange({ ...filters, dateFrom: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + + setDatePickerOpen(open ? 'to' : null)}> + + + + + { + onFiltersChange({ ...filters, dateTo: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + +
+ {(filters.dateFrom || filters.dateTo) && ( + + )} +
+ + {/* Content Type Filters */} +
+ +
+
+ + onFiltersChange({ ...filters, hasTodos: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasImages: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasFiles: checked ? true : undefined }) + } + /> + +
+
+
+ + {/* Priority Filter */} +
+
+ + onFiltersChange({ ...filters, priority: checked ? true : undefined }) + } + /> + +
+
+
+
+
+ + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/src/components/ExportImportModal.tsx b/src/components/ExportImportModal.tsx new file mode 100644 index 0000000..120d6d3 --- /dev/null +++ b/src/components/ExportImportModal.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Download, Upload, FileJson, FileSpreadsheet, AlertCircle, CheckCircle } from "lucide-react"; +import { Note } from "@/services/NotesService"; +import { Todo } from "@/services/TodosService"; +import { + exportToJSON, + downloadBackup, + downloadCSV, + exportNotesToCSV, + parseBackupJSON, + readFileAsText, + getBackupStats, + type BackupData +} from "@/utils/exportImport"; +import { showSuccess, showError } from "@/utils/toast"; + +interface ExportImportModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + notes: Note[]; + todos: Todo[]; + settings?: Record; + onImport?: (backup: BackupData) => Promise; +} + +export function ExportImportModal({ + open, + onOpenChange, + notes, + todos, + settings, + onImport +}: ExportImportModalProps) { + const [importing, setImporting] = useState(false); + const [importFile, setImportFile] = useState(null); + const [previewData, setPreviewData] = useState(null); + + const handleExportJSON = async () => { + try { + const jsonData = await exportToJSON(notes, todos, settings); + downloadBackup(jsonData); + showSuccess('Backup exporté avec succès'); + } catch (error) { + showError('Erreur lors de l\'export'); + } + }; + + const handleExportCSV = () => { + try { + const csvData = exportNotesToCSV(notes); + downloadCSV(csvData); + showSuccess('CSV exporté avec succès'); + } catch (error) { + showError('Erreur lors de l\'export CSV'); + } + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setImportFile(file); + + try { + const content = await readFileAsText(file); + const backup = parseBackupJSON(content); + + if (backup) { + setPreviewData(backup); + } else { + showError('Fichier de backup invalide'); + setImportFile(null); + } + } catch (error) { + showError('Erreur lors de la lecture du fichier'); + setImportFile(null); + } + }; + + const handleImport = async () => { + if (!previewData || !onImport) return; + + setImporting(true); + try { + await onImport(previewData); + showSuccess('Données importées avec succès'); + setImportFile(null); + setPreviewData(null); + onOpenChange(false); + } catch (error) { + showError('Erreur lors de l\'import'); + } finally { + setImporting(false); + } + }; + + const stats = notes.length > 0 ? { + notesCount: notes.length, + archivedCount: notes.filter(n => n.archived).length, + priorityCount: notes.filter(n => n.priority).length, + todosCount: todos.length, + tagsCount: new Set(notes.flatMap(n => n.tags?.map(t => t.name) || [])).size + } : null; + + const previewStats = previewData ? getBackupStats(previewData) : null; + + return ( + + + + Export / Import + + Exportez ou importez toutes vos données + + + + + + Export + Import + + + + {/* Current Data Stats */} + {stats && ( +
+
+
{stats.notesCount}
+
Notes
+
+
+
{stats.todosCount}
+
Tâches
+
+
+
{stats.tagsCount}
+
Tags
+
+
+ )} + + {/* Export Options */} +
+
+ + +

+ Inclut toutes les notes, tâches, tags, et paramètres +

+
+ +
+ + +

+ Tableau des notes pour Excel ou Google Sheets +

+
+
+ + + + + Les fichiers seront téléchargés dans votre dossier de téléchargements + + +
+ + + {/* File Upload */} +
+ + +
+ + {/* Preview */} + {previewData && previewStats && ( +
+
+ + Backup valide + {previewData.version} +
+ +
+
+
{previewStats.notesCount}
+
Notes
+
+
+
{previewStats.todosCount}
+
Tâches
+
+
+
{previewStats.tagsCount}
+
Tags
+
+
+ + + + + Créé le {new Date(previewData.exportDate).toLocaleDateString('fr-FR')} + + + + + + {!onImport && ( +

+ La fonction d'import n'est pas disponible +

+ )} +
+ )} + + {!previewData && !importFile && ( +
+ +

Sélectionnez un fichier de backup pour commencer

+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..4e5ac7b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react"; +import { Note } from "@/services/NotesService"; + +interface KanbanColumn { + id: string; + title: string; + color: string; +} + +const DEFAULT_COLUMNS: KanbanColumn[] = [ + { id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' }, + { id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' }, + { id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' }, + { id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' }, + { id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' }, +]; + +interface KanbanBoardProps { + notes: Note[]; + onNoteClick: (note: Note) => void; + onCreateNote: (columnId: string) => void; + onMoveNote?: (noteId: number, newStatus: string) => void; +} + +export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) { + const [draggedNote, setDraggedNote] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + + // Group notes by their kanban_status tag or default to 'backlog' + const getNoteStatus = (note: Note): string => { + const statusTag = note.tags?.find(tag => + DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase()) + ); + return statusTag?.name.toLowerCase() || 'backlog'; + }; + + const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => { + acc[column.id] = notes.filter(note => + !note.archived && getNoteStatus(note) === column.id + ); + return acc; + }, {} as Record); + + const handleDragStart = (note: Note) => { + setDraggedNote(note); + }; + + const handleDragOver = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = () => { + setDragOverColumn(null); + }; + + const handleDrop = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(null); + + if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) { + onMoveNote(draggedNote.id!, columnId); + } + setDraggedNote(null); + }; + + const handleDragEnd = () => { + setDraggedNote(null); + setDragOverColumn(null); + }; + + return ( +
+ {DEFAULT_COLUMNS.map((column) => ( +
handleDragOver(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > + + +
+ +
+ {column.title} + + {notesByColumn[column.id]?.length || 0} + + + +
+ + + +
+ {notesByColumn[column.id]?.map((note) => ( +
handleDragStart(note)} + onDragEnd={handleDragEnd} + onClick={() => onNoteClick(note)} + className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${ + draggedNote?.id === note.id ? 'opacity-50' : '' + } ${note.priority ? 'border-l-4 border-l-red-500' : ''}`} + > +
+ +
+

+ {note.title || "Sans titre"} +

+ {note.content && ( +

+ {note.content.replace(/<[^>]*>/g, '')} +

+ )} +
+
+ + {/* Metadata badges */} +
+ {note.todos && note.todos.length > 0 && ( + + + {note.todos.filter(t => t.completed).length}/{note.todos.length} + + )} + {note.images && note.images.length > 0 && ( + + + {note.images.length} + + )} + {note.files && note.files.length > 0 && ( + + + {note.files.length} + + )} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.slice(0, 2).map((tag) => ( + + + {tag.name} + + ))} + {note.tags.length > 2 && ( + + +{note.tags.length - 2} + + )} +
+ )} +
+ + {/* Date */} + {note.updated_at && ( +

+ {new Date(note.updated_at).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short' + })} +

+ )} +
+ ))} + {notesByColumn[column.id]?.length === 0 && ( +
+ Aucune note +
+ )} +
+
+
+ +
+ ))} +
+ ); +} diff --git a/src/components/KeyboardShortcutsHelp.tsx b/src/components/KeyboardShortcutsHelp.tsx new file mode 100644 index 0000000..deb161a --- /dev/null +++ b/src/components/KeyboardShortcutsHelp.tsx @@ -0,0 +1,82 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Keyboard } from "lucide-react"; +import { type KeyboardShortcut, formatShortcut } from "@/hooks/useKeyboardShortcuts"; + +interface KeyboardShortcutsHelpProps { + open: boolean; + onOpenChange: (open: boolean) => void; + shortcuts: KeyboardShortcut[]; +} + +export function KeyboardShortcutsHelp({ open, onOpenChange, shortcuts }: KeyboardShortcutsHelpProps) { + // Group shortcuts by category + const groupedShortcuts = shortcuts.reduce((acc, shortcut) => { + // Determine category based on description + let category = 'Général'; + + if (shortcut.description.toLowerCase().includes('note')) { + category = 'Notes'; + } else if (shortcut.description.toLowerCase().includes('recherch')) { + category = 'Recherche'; + } else if (shortcut.description.toLowerCase().includes('todo') || shortcut.description.toLowerCase().includes('tâche')) { + category = 'Tâches'; + } else if (shortcut.description.toLowerCase().includes('navigation')) { + category = 'Navigation'; + } else if (shortcut.description.toLowerCase().includes('édition') || shortcut.description.toLowerCase().includes('gras') || shortcut.description.toLowerCase().includes('italique')) { + category = 'Édition'; + } + + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(shortcut); + return acc; + }, {} as Record); + + return ( + + + + + + Raccourcis clavier + + + Utilisez ces raccourcis pour naviguer plus rapidement + + + + +
+ {Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => ( +
+

+ {category} +

+
+ {categoryShortcuts.map((shortcut, index) => ( +
+ {shortcut.description} + + {formatShortcut(shortcut)} + +
+ ))} +
+
+ ))} +
+
+ +
+ Appuyez sur ? pour afficher cette aide +
+
+
+ ); +} diff --git a/src/components/PomodoroTimer.tsx b/src/components/PomodoroTimer.tsx new file mode 100644 index 0000000..e35b371 --- /dev/null +++ b/src/components/PomodoroTimer.tsx @@ -0,0 +1,197 @@ +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 { Play, Pause, RotateCcw, Clock, Coffee, Trophy } from "lucide-react"; +import { showSuccess } from "@/utils/toast"; + +type TimerMode = 'work' | 'shortBreak' | 'longBreak'; + +const TIMER_DURATIONS = { + work: 25 * 60, // 25 minutes + shortBreak: 5 * 60, // 5 minutes + longBreak: 15 * 60, // 15 minutes +}; + +const SESSIONS_BEFORE_LONG_BREAK = 4; + +export function PomodoroTimer() { + const [mode, setMode] = useState('work'); + const [timeLeft, setTimeLeft] = useState(TIMER_DURATIONS.work); + const [isRunning, setIsRunning] = useState(false); + const [sessionsCompleted, setSessionsCompleted] = useState(0); + const intervalRef = useRef(null); + const audioRef = useRef(null); + + useEffect(() => { + // Create audio element for notification + audioRef.current = new Audio('data:audio/wav;base64,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'); + }, []); + + useEffect(() => { + if (isRunning && timeLeft > 0) { + intervalRef.current = setInterval(() => { + setTimeLeft(prev => { + if (prev <= 1) { + handleTimerComplete(); + return 0; + } + return prev - 1; + }); + }, 1000); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [isRunning, timeLeft]); + + const handleTimerComplete = () => { + setIsRunning(false); + + // Play notification sound + if (audioRef.current) { + audioRef.current.play().catch(e => console.error('Audio play failed:', e)); + } + + if (mode === 'work') { + const newSessionCount = sessionsCompleted + 1; + setSessionsCompleted(newSessionCount); + + // Determine next break type + if (newSessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) { + setMode('longBreak'); + setTimeLeft(TIMER_DURATIONS.longBreak); + showSuccess(`Session terminée ! Temps pour une longue pause (${TIMER_DURATIONS.longBreak / 60} min)`); + } else { + setMode('shortBreak'); + setTimeLeft(TIMER_DURATIONS.shortBreak); + showSuccess(`Session terminée ! Temps pour une courte pause (${TIMER_DURATIONS.shortBreak / 60} min)`); + } + } else { + // Break completed, back to work + setMode('work'); + setTimeLeft(TIMER_DURATIONS.work); + showSuccess('Pause terminée ! Prêt pour une nouvelle session ?'); + } + }; + + const handleStart = () => { + setIsRunning(true); + }; + + const handlePause = () => { + setIsRunning(false); + }; + + const handleReset = () => { + setIsRunning(false); + setTimeLeft(TIMER_DURATIONS[mode]); + }; + + const handleModeChange = (newMode: TimerMode) => { + setMode(newMode); + setTimeLeft(TIMER_DURATIONS[newMode]); + setIsRunning(false); + }; + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const progress = ((TIMER_DURATIONS[mode] - timeLeft) / TIMER_DURATIONS[mode]) * 100; + + return ( + + + + + Pomodoro Timer + + + {sessionsCompleted} session{sessionsCompleted !== 1 ? 's' : ''} + + + + + {/* Mode Selector */} +
+ + + +
+ + {/* Timer Display */} +
+
+ {formatTime(timeLeft)} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + + )} + +
+ + {/* Session Info */} +
+ {mode === 'work' && ( +

Session {(sessionsCompleted % SESSIONS_BEFORE_LONG_BREAK) + 1} sur {SESSIONS_BEFORE_LONG_BREAK}

+ )} + {mode === 'shortBreak' &&

Pause courte - Profitez-en pour vous étirer

} + {mode === 'longBreak' &&

Pause longue - Temps de vraiment déconnecter

} +
+ + + ); +} diff --git a/src/components/QuickCaptureWidget.tsx b/src/components/QuickCaptureWidget.tsx new file mode 100644 index 0000000..d69140d --- /dev/null +++ b/src/components/QuickCaptureWidget.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Zap, Send } from "lucide-react"; +import NotesService from "@/services/NotesService"; +import { showSuccess, showError } from "@/utils/toast"; + +interface QuickCaptureWidgetProps { + onNoteCaptured?: () => void; +} + +export function QuickCaptureWidget({ onNoteCaptured }: QuickCaptureWidgetProps) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleCapture = async () => { + if (!content.trim()) { + showError("Le contenu ne peut pas être vide"); + return; + } + + setIsSubmitting(true); + try { + const noteTitle = title.trim() || `Note rapide - ${new Date().toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit' + })}`; + + await NotesService.createNote(noteTitle, content); + showSuccess("Note capturée avec succès"); + + // Reset form + setTitle(""); + setContent(""); + setOpen(false); + + // Notify parent component + if (onNoteCaptured) { + onNoteCaptured(); + } + } catch (error) { + showError("Erreur lors de la capture de la note"); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Cmd/Ctrl + Enter to submit + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + handleCapture(); + } + + // Escape to close + if (e.key === 'Escape') { + setOpen(false); + } + }; + + return ( + <> + {/* Floating Action Button */} + + + {/* Quick Capture Modal */} + + + + + + Capture rapide + + + Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer. + + + +
+ {/* Optional Title */} +
+ setTitle(e.target.value)} + className="text-lg font-medium" + autoFocus={false} + /> +

+ Si vide, un titre avec la date sera généré automatiquement +

+
+ + {/* Content */} +