import { useEffect, useState, useRef, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List, Sparkles } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; import NotesService, { Note } from "@/services/NotesService"; import TodosService, { Todo } from "@/services/TodosService"; import RssService, { RssFeed, RssArticle } from "@/services/RssService"; import SettingsService, { Settings as AppSettings } from "@/services/SettingsService"; import CalendarService, { CalendarEvent } from "@/services/CalendarService"; import TagsService, { Tag } from "@/services/TagsService"; import OpenRouterService, { OpenRouterModel } from "@/services/OpenRouterService"; import { useNavigate } from "react-router-dom"; import { showError, showSuccess } from "@/utils/toast"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; import { ModeToggle } from "@/components/mode-toggle"; import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; import { PomodoroTimer } from "@/components/PomodoroTimer"; import { KanbanBoard } from "@/components/KanbanBoard"; import { getSmartTagSuggestions } from "@/utils/smartTags"; import { QuickCaptureWidget } from "@/components/QuickCaptureWidget"; import { StatsDashboard } from "@/components/StatsDashboard"; import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp"; import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== /** * Convertit une date ISO/UTC en format datetime-local pour Europe/Paris * @param date - Date ISO string ou Date object * @returns String au format "YYYY-MM-DDTHH:mm" en heure Europe/Paris */ function toLocalDateTimeString(date: string | Date): string { const d = typeof date === 'string' ? new Date(date) : date; // Formater en heure Europe/Paris const formatter = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); const parts = formatter.formatToParts(d); const year = parts.find(p => p.type === 'year')?.value; const month = parts.find(p => p.type === 'month')?.value; const day = parts.find(p => p.type === 'day')?.value; const hour = parts.find(p => p.type === 'hour')?.value; const minute = parts.find(p => p.type === 'minute')?.value; return `${year}-${month}-${day}T${hour}:${minute}`; } /** * Convertit une valeur datetime-local en ISO string pour Europe/Paris * @param localDateTimeString - String au format "YYYY-MM-DDTHH:mm" (sans timezone) * @returns ISO string représentant cette heure en Europe/Paris */ function toParisISO(localDateTimeString: string): string { // Input: "2024-11-16T14:30" signifie 14:30 à Paris // Output: ISO UTC correspondant (ex: "2024-11-16T13:30:00.000Z" en hiver) const [datePart, timePart] = localDateTimeString.split('T'); const [year, month, day] = datePart.split('-').map(Number); const [hour, minute] = timePart.split(':').map(Number); // Déterminer l'offset de Europe/Paris pour cette date spécifique // (pour gérer automatiquement l'heure d'été/hiver) // Créer une date test en UTC à midi const testDate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); // Formater cette date en Europe/Paris const parisHour = parseInt(testDate.toLocaleString('en-US', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false })); // Calculer l'offset (normalement +1 en hiver, +2 en été) const offset = parisHour - 12; const offsetString = offset === 1 ? '+01:00' : '+02:00'; // Construire l'ISO string avec le timezone de Paris const isoWithTZ = `${datePart}T${timePart.padEnd(5, '0')}:00${offsetString}`; // Créer la date (JavaScript va automatiquement convertir en UTC) return new Date(isoWithTZ).toISOString(); } interface UserType { id: number; username: string; is_admin: boolean; } const Index = () => { const [user, setUser] = useState(null); const [showAdmin, setShowAdmin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchFilters, setSearchFilters] = useState({ tags: [], dateFrom: undefined, dateTo: undefined, hasTodos: undefined, hasImages: undefined, hasFiles: undefined, priority: undefined }); const [notes, setNotes] = useState([]); const [openNote, setOpenNote] = useState(null); const [users, setUsers] = useState([]); const [todos, setTodos] = useState([]); const [rssFeeds, setRssFeeds] = useState([]); const [rssArticles, setRssArticles] = useState([]); const [calendarEvents, setCalendarEvents] = useState([]); const [settings, setSettings] = useState({}); const [adminTab, setAdminTab] = useState("users"); const [showArchived, setShowArchived] = useState(false); const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list'); const [noteTags, setNoteTags] = useState([]); const [openRouterModels, setOpenRouterModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); const [currentDateTime, setCurrentDateTime] = useState(new Date()); const [upcomingAlert, setUpcomingAlert] = useState(null); const [calendarAuthStatus, setCalendarAuthStatus] = useState<{ isAuthenticated: boolean; isExpired: boolean; needsReauth: boolean; authType?: 'oauth2' | 'service_account' | 'api_externe'; }>({ isAuthenticated: false, isExpired: false, needsReauth: true }); const navigate = useNavigate(); // Modal states const [deleteNoteModal, setDeleteNoteModal] = useState(false); const [addTodoModal, setAddTodoModal] = useState(false); const [addRssFeedModal, setAddRssFeedModal] = useState(false); const [addUserModal, setAddUserModal] = useState(false); const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); const [addNoteTodoModal, setAddNoteTodoModal] = useState(false); const [addTagModal, setAddTagModal] = useState(false); const [selectedImage, setSelectedImage] = useState(null); const [addEventModal, setAddEventModal] = useState(false); const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false }); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [showStatsDashboard, setShowStatsDashboard] = useState(false); // Pagination states const [notesPage, setNotesPage] = useState(0); const [todosActivePage, setTodosActivePage] = useState(0); const [todosCompletedPage, setTodosCompletedPage] = useState(0); const [rssPage, setRssPage] = useState(0); const [calendarPage, setCalendarPage] = useState(0); const NOTES_PER_PAGE = 5; const TODOS_PER_PAGE = 15; const RSS_MAX_ARTICLES = 14; const RSS_PER_PAGE = 8; const CALENDAR_PER_PAGE = 10; // Chatbox states const [chatOpen, setChatOpen] = useState(false); const [chatMessages, setChatMessages] = useState>([]); const [chatInput, setChatInput] = useState(""); const [chatLoading, setChatLoading] = useState(false); const [selectedModel, setSelectedModel] = useState(""); const [defaultModel, setDefaultModel] = useState(""); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); // Debounce timer for auto-save const saveTimerRef = useRef(null); const chatEndRef = useRef(null); // Update current date/time every second useEffect(() => { const timer = setInterval(() => { setCurrentDateTime(new Date()); }, 1000); return () => clearInterval(timer); }, []); // Check for upcoming events every minute useEffect(() => { const checkUpcomingEvents = () => { const now = new Date(); const thirtyMinutesFromNow = new Date(now.getTime() + 30 * 60 * 1000); const upcomingEvent = calendarEvents.find(event => { const eventStart = new Date(event.start_time); return eventStart > now && eventStart <= thirtyMinutesFromNow; }); setUpcomingAlert(upcomingEvent || null); }; checkUpcomingEvents(); const timer = setInterval(checkUpcomingEvents, 60000); // Check every minute return () => clearInterval(timer); }, [calendarEvents]); useEffect(() => { const userFromAuth = AuthService.getUser(); if (userFromAuth) { setUser(userFromAuth); loadNotes(); loadTodos(); loadRssFeeds(); loadRssArticles(); loadCalendarEvents(); } else { navigate("/login"); } }, [navigate]); // Auto-refresh des articles RSS toutes les 2 minutes useEffect(() => { const interval = setInterval(() => { loadRssArticles(); }, 2 * 60 * 1000); // 2 minutes (synchronisé avec le scheduler backend) return () => clearInterval(interval); }, []); const loadNotes = async () => { try { const fetchedNotes = await NotesService.getNotes(); if (Array.isArray(fetchedNotes)) { setNotes(fetchedNotes); } } catch (error) { console.error("Erreur lors du chargement des notes:", error); } }; const loadTodos = async () => { try { const fetchedTodos = await TodosService.getTodos(); if (Array.isArray(fetchedTodos)) { setTodos(fetchedTodos); } } catch (error) { console.error("Erreur lors du chargement des todos:", error); } }; const loadRssFeeds = async () => { try { const feeds = await RssService.getFeeds(); if (Array.isArray(feeds)) { setRssFeeds(feeds); } } catch (error) { console.error("Erreur lors du chargement des flux RSS:", error); } }; const loadRssArticles = async () => { try { const articles = await RssService.getArticles(100); if (Array.isArray(articles)) { // Les articles arrivent triés par date DESC (récent en premier) depuis le backend setRssArticles(articles); } } catch (error) { console.error("Erreur lors du chargement des articles RSS:", error); } }; const loadCalendarEvents = async () => { try { const events = await CalendarService.getEvents(50); // Charger 50 événements pour pagination if (Array.isArray(events)) { setCalendarEvents(events); } } catch (error) { console.error("Erreur lors du chargement des événements:", error); } }; const loadUsers = async () => { if (user?.is_admin) { try { const fetchedUsers = await AdminService.getUsers(); if (Array.isArray(fetchedUsers)) { setUsers(fetchedUsers); } } catch (error) { console.error("Erreur lors du chargement des utilisateurs:", error); } } }; const loadSettings = async () => { if (user?.is_admin) { try { const fetchedSettings = await SettingsService.getSettings(); setSettings(fetchedSettings || {}); } catch (error) { console.error("Erreur lors du chargement des paramètres:", error); } } }; const loadOpenRouterModels = async () => { setLoadingModels(true); try { const models = await OpenRouterService.getModels(); setOpenRouterModels(models); } catch (error) { console.error("Erreur lors du chargement des modèles:", error); setOpenRouterModels([]); // Vider en cas d'erreur } finally { setLoadingModels(false); } }; useEffect(() => { if (showAdmin) { loadUsers(); loadSettings(); } }, [showAdmin]); useEffect(() => { if (showAdmin && adminTab === 'openrouter' && openRouterModels.length === 0) { loadOpenRouterModels(); } }, [showAdmin, adminTab]); useEffect(() => { if (showAdmin && adminTab === 'calendar') { checkCalendarAuthStatus(); } }, [showAdmin, adminTab]); const checkCalendarAuthStatus = async () => { try { const status = await CalendarService.getAuthStatus(); setCalendarAuthStatus(status); } catch (error) { console.error('Erreur lors de la vérification du statut OAuth:', error); } }; const handleGoogleConnect = async () => { try { const authUrl = await CalendarService.getAuthUrl(); // Ouvrir une popup pour l'authentification OAuth const width = 600; const height = 700; const left = window.screen.width / 2 - width / 2; const top = window.screen.height / 2 - height / 2; const popup = window.open( authUrl, 'GoogleAuth', `width=${width},height=${height},top=${top},left=${left}` ); // Écouter le message de la popup const handleMessage = (event: MessageEvent) => { if (event.data.type === 'google-auth-success') { showSuccess('Authentification Google réussie'); checkCalendarAuthStatus(); window.removeEventListener('message', handleMessage); } else if (event.data.type === 'google-auth-error') { showError(`Erreur d'authentification: ${event.data.error}`); window.removeEventListener('message', handleMessage); } }; window.addEventListener('message', handleMessage); // Fermer le listener si la popup est fermée manuellement const checkPopup = setInterval(() => { if (popup && popup.closed) { clearInterval(checkPopup); window.removeEventListener('message', handleMessage); } }, 1000); } catch (error) { showError('Erreur lors de la génération de l\'URL d\'authentification'); } }; const handleGoogleDisconnect = async () => { try { const success = await CalendarService.disconnect(); if (success) { showSuccess('Déconnecté de Google Calendar'); checkCalendarAuthStatus(); } } catch (error) { showError('Erreur lors de la déconnexion'); } }; const handleLogout = () => { AuthService.logout(); navigate("/login"); }; const handleCreateNote = () => { setTemplateSelectorOpen(true); }; const handleCreateNoteFromTemplate = async (template: NoteTemplate) => { try { const title = template.name === "Note vide" ? "Nouvelle note" : template.name; const newNote = await NotesService.createNote(title, template.content); if (newNote) { // Add todos if template has any if (template.todos && template.todos.length > 0) { for (const todo of template.todos) { await NotesService.addTodo(newNote.id!, todo.text); } // Reload note to get the todos const notes = await NotesService.getNotes(); const updatedNote = notes.find(n => n.id === newNote.id); if (updatedNote) { setNotes([updatedNote, ...notes.filter(n => n.id !== newNote.id)]); setOpenNote(updatedNote); } } else { setNotes([newNote, ...notes]); setOpenNote(newNote); } showSuccess("Note créée depuis le template"); } } catch (error) { showError("Erreur lors de la création de la note"); } }; const loadNoteTags = async (noteId: number) => { try { const tags = await TagsService.getTags(noteId); setNoteTags(tags || []); // Synchroniser avec openNote.tags (convertir de 'tag' vers 'name') if (openNote && openNote.id === noteId) { setOpenNote({ ...openNote, tags: (tags || []).map(t => ({ id: t.id, name: t.tag })) }); } } catch (error) { console.error("Erreur lors du chargement des tags:", error); setNoteTags([]); } }; const handleOpenNote = (note: Note) => { setOpenNote(note); if (note.id) { loadNoteTags(note.id); } }; const handleCloseNote = () => { setOpenNote(null); setNoteTags([]); }; // Update note with immediate local state update const handleUpdateNote = useCallback(async (updatedFields: Partial) => { if (!openNote) return; const updatedNote = { ...openNote, ...updatedFields, todos: updatedFields.todos || openNote.todos || [], images: updatedFields.images || openNote.images || [], files: updatedFields.files || openNote.files || [] }; // Update local state immediately setOpenNote(updatedNote); setNotes(prev => prev.map(note => note.id === updatedNote.id ? updatedNote : note)); try { const savedNote = await NotesService.updateNote(updatedNote); if (savedNote) { // Mettre à jour avec les données du serveur (updated_at, etc.) setOpenNote(savedNote); setNotes(prev => prev.map(note => note.id === savedNote.id ? savedNote : note)); } else { showError("Erreur lors de la sauvegarde"); } } catch (error) { showError("Erreur lors de la mise à jour de la note"); } }, [openNote, setOpenNote, setNotes]); // Debounced content update for typing const handleContentChange = useCallback((content: string) => { if (!openNote) return; // Don't update local state to avoid re-render during typing // Just save to API in background const updatedNote = { ...openNote, content }; // Debounce the API call if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } saveTimerRef.current = setTimeout(async () => { try { const savedNote = await NotesService.updateNote(updatedNote); if (savedNote) { // Mettre à jour avec les données du serveur (updated_at, etc.) setOpenNote(savedNote); setNotes(prev => prev.map(note => note.id === savedNote.id ? savedNote : note)); } } catch (error) { showError("Erreur lors de la sauvegarde automatique"); } }, 1000); // Save after 1 second of inactivity }, [openNote, setNotes]); // Clean up timer on unmount useEffect(() => { return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } }; }, []); const confirmDeleteNote = async () => { if (!openNote?.id) return; try { const success = await NotesService.deleteNote(openNote.id); if (success) { const updatedNotes = notes.filter(note => note.id !== openNote.id); setNotes(updatedNotes); setOpenNote(null); showSuccess("Note supprimée"); } } catch (error) { showError("Erreur lors de la suppression"); } }; const confirmAddTodo = async (text: string) => { try { const newTodo = await TodosService.createTodo(text); if (newTodo) { setTodos([newTodo, ...todos]); showSuccess("Tâche ajoutée"); } } catch (error) { showError("Erreur lors de l'ajout de la tâche"); } }; const handleToggleTodo = async (todoId: number) => { try { await TodosService.toggleComplete(todoId); setTodos(todos.map(t => t.id === todoId ? { ...t, completed: !t.completed } : t)); } catch (error) { showError("Erreur lors de la mise à jour"); } }; const handleToggleTodoPriority = async (todoId: number) => { try { await TodosService.togglePriority(todoId); setTodos(todos.map(t => t.id === todoId ? { ...t, priority: !t.priority } : t)); } catch (error) { showError("Erreur lors de la mise à jour de la priorité"); } }; const handleDeleteTodo = async (todoId: number) => { try { await TodosService.deleteTodo(todoId); setTodos(todos.filter(t => t.id !== todoId)); showSuccess("Tâche supprimée"); } catch (error) { showError("Erreur lors de la suppression"); } }; const handleToggleTodoInProgress = async (todoId: number) => { try { await TodosService.toggleInProgress(todoId); setTodos(todos.map(t => t.id === todoId ? { ...t, in_progress: !t.in_progress } : t)); } catch (error) { showError("Erreur lors de la mise à jour du statut"); } }; const confirmAddRssFeed = async (url: string) => { try { const newFeed = await RssService.addFeed(url); if (newFeed) { setRssFeeds([...rssFeeds, newFeed]); showSuccess("Flux RSS ajouté"); loadRssArticles(); } } catch (error) { showError("Erreur lors de l'ajout du flux"); } }; const handleDeleteRssFeed = async (feedId: number) => { try { await RssService.deleteFeed(feedId); setRssFeeds(rssFeeds.filter(f => f.id !== feedId)); showSuccess("Flux supprimé"); loadRssArticles(); } catch (error) { showError("Erreur lors de la suppression"); } }; const handleRefreshRss = async () => { try { await RssService.refreshFeeds(); loadRssArticles(); showSuccess("Flux actualisés"); } catch (error) { showError("Erreur lors de l'actualisation"); } }; const handleSendMessage = async () => { if (!chatInput.trim() || !selectedModel || chatLoading) return; const userMessage = { role: 'user', content: chatInput }; const newMessages = [...chatMessages, userMessage]; setChatMessages(newMessages); setChatInput(""); setChatLoading(true); try { const response = await OpenRouterService.sendMessage(selectedModel, newMessages); setChatMessages([...newMessages, { role: 'assistant', content: response }]); } catch (error: any) { showError(error.message || 'Erreur lors de l\'envoi du message'); setChatMessages([...newMessages, { role: 'assistant', content: 'Erreur: ' + error.message }]); } finally { setChatLoading(false); } }; const handleClearChat = () => { setChatMessages([]); }; // Scroll to bottom when new message arrives useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chatMessages]); // Load default model from localStorage on mount useEffect(() => { const savedDefaultModel = localStorage.getItem('defaultAiModel'); if (savedDefaultModel) { setDefaultModel(savedDefaultModel); } }, []); // Load models when chatbox opens useEffect(() => { if (chatOpen && openRouterModels.length === 0) { loadOpenRouterModels(); } }, [chatOpen]); // Set default model when models are loaded useEffect(() => { if (openRouterModels.length > 0 && !selectedModel) { // If there's a default model saved, use it if (defaultModel && openRouterModels.find(m => m.id === defaultModel)) { setSelectedModel(defaultModel); } else { // Otherwise use the first model setSelectedModel(openRouterModels[0].id); } } }, [openRouterModels, selectedModel, defaultModel]); // Save default model to localStorage const handleSetDefaultModel = (modelId: string) => { setDefaultModel(modelId); localStorage.setItem('defaultAiModel', modelId); showSuccess("Modèle par défaut défini"); }; const confirmAddUser = async (username: string, password: string, isAdmin: boolean) => { try { const newUser = await AdminService.createUser(username, password, isAdmin); if (newUser) { loadUsers(); showSuccess("Utilisateur créé"); } } catch (error) { showError("Erreur lors de la création"); } }; const confirmDeleteUser = async () => { if (!deleteUserModal.userId) return; try { const success = await AdminService.deleteUser(deleteUserModal.userId); if (success) { loadUsers(); showSuccess("Utilisateur supprimé"); } } catch (error) { showError("Erreur lors de la suppression"); } }; const confirmChangePassword = async (newPassword: string) => { if (!changePasswordModal.userId) return; try { const success = await AdminService.updateUserPassword(changePasswordModal.userId, newPassword); if (success) { showSuccess("Mot de passe modifié"); } } catch (error) { showError("Erreur lors de la modification"); } }; const handleSaveSettings = async () => { try { await SettingsService.updateSettings(settings); showSuccess("Paramètres enregistrés"); } catch (error) { showError("Erreur lors de l'enregistrement"); } }; const confirmAddTag = async (tagText: string) => { if (!openNote?.id) return; try { const newTag = await TagsService.addTag(openNote.id, tagText); if (newTag) { const updatedNoteTags = [...noteTags, newTag]; setNoteTags(updatedNoteTags); // Synchroniser avec openNote.tags (convertir de 'tag' vers 'name') setOpenNote({ ...openNote, tags: updatedNoteTags.map(t => ({ id: t.id, name: t.tag })) }); showSuccess("Tag ajouté"); } } catch (error) { showError("Erreur lors de l'ajout du tag"); } }; const handleDeleteTag = async (tagId: number) => { if (!openNote?.id) return; try { const success = await TagsService.deleteTag(openNote.id, tagId); if (success) { const updatedNoteTags = noteTags.filter(t => t.id !== tagId); setNoteTags(updatedNoteTags); // Synchroniser avec openNote.tags (convertir de 'tag' vers 'name') setOpenNote({ ...openNote, tags: updatedNoteTags.map(t => ({ id: t.id, name: t.tag })) }); showSuccess("Tag supprimé"); } } catch (error) { showError("Erreur lors de la suppression du tag"); } }; const handleFileUpload = async (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0] && openNote?.id) { const formData = new FormData(); formData.append('file', e.target.files[0]); try { const response = await fetch(`/api/notes/${openNote.id}/files`, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: formData }); if (response.ok) { const result = await response.json(); const newFile = result.file || result; const updatedFiles = [...(openNote.files || []), newFile]; setOpenNote({ ...openNote, files: updatedFiles }); await handleUpdateNote({ files: updatedFiles }); showSuccess("Fichier ajouté"); } } catch (error) { showError("Erreur lors de l'upload"); } finally { e.target.value = ''; } } }; // Extract unique tags from all notes const availableTags: TagOption[] = Array.from( new Map( notes.flatMap(note => (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }]) ) ).values() ).sort((a, b) => a.name.localeCompare(b.name)); const filteredNotes = notes.filter(note => { // Archived filter if (showArchived ? !note.archived : note.archived) return false; // Text search if (searchQuery !== "" && !(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) && !(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) { return false; } // Tags filter if (searchFilters.tags.length > 0) { const noteTags = (note.tags || []).map(t => t.name); if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) { return false; } } // Date range filter if (searchFilters.dateFrom || searchFilters.dateTo) { const noteDate = new Date(note.updated_at || note.created_at || 0); if (searchFilters.dateFrom) { const fromDate = new Date(searchFilters.dateFrom); fromDate.setHours(0, 0, 0, 0); if (noteDate < fromDate) return false; } if (searchFilters.dateTo) { const toDate = new Date(searchFilters.dateTo); toDate.setHours(23, 59, 59, 999); if (noteDate > toDate) return false; } } // Has todos filter if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) { return false; } // Has images filter if (searchFilters.hasImages && (!note.images || note.images.length === 0)) { return false; } // Has files filter if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) { return false; } // Priority filter if (searchFilters.priority && !note.priority) { return false; } return true; }); // Pagination for notes const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); const paginatedNotes = filteredNotes.slice( notesPage * NOTES_PER_PAGE, (notesPage + 1) * NOTES_PER_PAGE ); // Reset page when changing filters or search useEffect(() => { setNotesPage(0); }, [showArchived, searchQuery, searchFilters]); useEffect(() => { setTodosActivePage(0); setTodosCompletedPage(0); }, [todos.length]); useEffect(() => { setRssPage(0); }, [rssArticles.length]); useEffect(() => { setCalendarPage(0); }, [calendarEvents.length]); // Define keyboard shortcuts const keyboardShortcuts: KeyboardShortcut[] = [ { key: 'n', ctrl: true, description: 'Nouvelle note', handler: () => !showAdmin && handleCreateNote() }, { key: 'k', ctrl: true, description: 'Rechercher', handler: () => { const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement; if (searchInput) searchInput.focus(); } }, { key: 'q', ctrl: true, description: 'Capture rapide', handler: () => { const captureButton = document.querySelector('[title="Capture rapide (Ctrl+Q)"]') as HTMLButtonElement; if (captureButton) captureButton.click(); } }, { key: 'Escape', description: 'Fermer note/modal', handler: () => { if (openNote) setOpenNote(null); else if (showAdmin) setShowAdmin(false); else if (showKeyboardHelp) setShowKeyboardHelp(false); }, preventDefault: false }, { key: '?', shift: true, description: 'Aide raccourcis', handler: () => setShowKeyboardHelp(true) }, { key: 'e', ctrl: true, description: 'Archiver note', handler: async () => { if (openNote?.id) { await handleUpdateNote({ archived: !openNote.archived }); } } }, { key: 's', ctrl: true, description: 'Sauvegarder (auto)', handler: () => { // Note: Auto-save is handled by the editor showSuccess('Auto-save actif'); } } ]; // Register keyboard shortcuts useKeyboardShortcuts(keyboardShortcuts, !showAdmin); // Pagination for todos const activeTodos = todos.filter(t => !t.completed); const completedTodos = todos.filter(t => t.completed); const totalActiveTodosPages = Math.ceil(activeTodos.length / TODOS_PER_PAGE); const totalCompletedTodosPages = Math.ceil(completedTodos.length / TODOS_PER_PAGE); const paginatedActiveTodos = activeTodos.slice( todosActivePage * TODOS_PER_PAGE, (todosActivePage + 1) * TODOS_PER_PAGE ); const paginatedCompletedTodos = completedTodos.slice( todosCompletedPage * TODOS_PER_PAGE, (todosCompletedPage + 1) * TODOS_PER_PAGE ); // Pagination for RSS const totalRssPages = Math.ceil(rssArticles.length / RSS_PER_PAGE); const paginatedRssArticles = rssArticles.slice( rssPage * RSS_PER_PAGE, (rssPage + 1) * RSS_PER_PAGE ); // Pagination for Calendar const totalCalendarPages = Math.ceil(calendarEvents.length / CALENDAR_PER_PAGE); const paginatedCalendarEvents = calendarEvents.slice( calendarPage * CALENDAR_PER_PAGE, (calendarPage + 1) * CALENDAR_PER_PAGE ); return (
{/* Top Navigation */}
{/* Alert bar for upcoming events */} {upcomingAlert && (
Rendez-vous dans moins de 30 minutes : {upcomingAlert.title} à {new Date(upcomingAlert.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
)}
NoteFlow
{currentDateTime.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
{currentDateTime.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{user && ( Mon compte {user.is_admin && ( setShowAdmin(true)}> Administration )} Déconnexion )}
{/* Main Content */}
{/* Three Column Layout: Calendar Left | Notes Middle | Todo+RSS Right */}
{/* Left Column: Calendar */}
Rendez-vous
{calendarEvents.length > 0 ? ( paginatedCalendarEvents.map(event => { const startDate = new Date(event.start_time); const endDate = new Date(event.end_time); const isToday = startDate.toDateString() === new Date().toDateString(); const isSoon = startDate.getTime() - new Date().getTime() < 30 * 60 * 1000 && startDate > new Date(); // Détecter les événements "toute la journée" (all_day OU heure à 00:00 ou 01:00) const isAllDay = event.all_day || (startDate.getHours() === 0 && startDate.getMinutes() === 0) || (startDate.getHours() === 1 && startDate.getMinutes() === 0 && endDate.getHours() === 1); return (
event.html_link && window.open(event.html_link, '_blank')} >

{event.title}

{isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', timeZone: 'Europe/Paris' })} {isAllDay ? ' - Toute la journée' : ` à ${startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}`}

{event.location && (

📍 {event.location}

)} {isSoon && ( Dans moins de 30 min )}
event.html_link && window.open(event.html_link, '_blank')} />
); }) ) : (

Aucun rendez-vous à venir

)}
{totalCalendarPages > 1 && (
{calendarPage + 1}/{totalCalendarPages}
)}
{/* Statistics Dashboard */}
{/* Middle Column: Notes or Open Note */}
{!openNote ? ( <> {/* Notes Grid Header */}

Mes Notes

setSearchQuery(e.target.value)} />
{/* View Mode Toggle */}
{/* Notes View - List or Kanban */} {notesViewMode === 'list' ? ( <>
{filteredNotes.length > 0 ? ( paginatedNotes.map(note => ( handleOpenNote(note)} > {/* Priority icon */} {/* Archive icon */}

{note.title || "Sans titre"}

{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}

{note.todos && note.todos.length > 0 && ( {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.map((tag) => ( {tag.name} )) )}
{note.updated_at && (

Modifiée le {new Date(note.updated_at).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })}

)}
)) ) : (

{searchQuery ? "Aucune note trouvée" : "Aucune note"}

{!searchQuery && ( )}
)}
{/* Pagination pour les notes */} {totalNotesPages > 1 && (
Page {notesPage + 1} sur {totalNotesPages}
)} ) : ( /* Kanban View */ { setTemplateSelectorOpen(true); // TODO: Set initial tag based on columnId }} onMoveNote={async (noteId, newStatus) => { // Move note to new column by updating its status tag const note = notes.find(n => n.id === noteId); if (!note) return; // Remove old status tags const oldStatusTags = note.tags?.filter(tag => ['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase()) ) || []; for (const tag of oldStatusTags) { if (tag.id && note.id) { await TagsService.deleteTag(note.id, tag.id); } } // Add new status tag if (note.id) { await TagsService.addTag(note.id, newStatus); await loadNotes(); // Refresh notes showSuccess(`Note déplacée vers ${newStatus}`); } }} /> )} ) : ( /* Open Note Editor */
handleUpdateNote({ title: e.target.value })} /> {/* Action buttons */}
{ if (e.target.files && e.target.files[0] && openNote?.id) { const originalFile = e.target.files[0]; const originalSize = formatFileSize(originalFile.size); try { // Compresser l'image avant l'upload const compressedFile = await compressImage(originalFile); const compressedSize = formatFileSize(compressedFile.size); // Afficher un message si l'image a été compressée if (compressedFile.size < originalFile.size) { showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`); } // Vérifier la taille finale (5MB max après compression) const maxSize = 5 * 1024 * 1024; // 5MB if (compressedFile.size > maxSize) { showError("L'image est trop grande même après compression (max 5MB)"); e.target.value = ''; return; } const image = await NotesService.uploadImage(openNote.id, compressedFile); if (image) { const updatedImages = [...(openNote.images || []), image]; setOpenNote({ ...openNote, images: updatedImages }); setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, images: updatedImages } : note)); showSuccess("Image ajoutée"); } else { showError("Erreur lors de l'upload de l'image"); } } catch (error) { showError(error instanceof Error ? error.message : "Erreur lors de l'upload de l'image"); } e.target.value = ''; } }} />
{/* Tags */} {noteTags.length > 0 && (
{noteTags.map((tag) => ( {tag.tag} tag.id && handleDeleteTag(tag.id)} /> ))}
)} {/* Smart Tag Suggestions */} {openNote && (() => { const existingTags = noteTags.map(t => t.tag); const suggestions = getSmartTagSuggestions(openNote, notes, existingTags); return suggestions.length > 0 && (

Suggestions de tags

{suggestions.map((suggestion) => ( confirmAddTag(suggestion)} > {suggestion} ))}
); })()} {/* Todos */} {openNote.todos && openNote.todos.length > 0 && ( <>
todo list:
{openNote.todos.map((todo, index) => (
{ if (!todo.id) return; const success = await NotesService.toggleTodo(todo.id, !todo.completed); if (success) { const updatedTodos = [...(openNote.todos || [])]; updatedTodos[index] = { ...todo, completed: !todo.completed }; setOpenNote({ ...openNote, todos: updatedTodos }); setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note)); } }} /> {todo.text}
))}
)} {/* Images in thumbnails */} {openNote.images && openNote.images.length > 0 && (
{openNote.images.map((image, index) => (
{image.original_name} setSelectedImage(`/uploads/images/${image.filename}`)} />
))}
)} {/* Files */} {openNote.files && openNote.files.length > 0 && ( <>
Fichiers divers : Fichier(s)
{openNote.files.map((file: any, index: number) => (

{file.original_name || file.filename}

{file.size && (

{(file.size / 1024).toFixed(2)} KB

)}
))}
)} {/* Rich text editor */} {/* Note metadata */}
Créée le: {openNote.created_at ? new Date(openNote.created_at).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'N/A'}
Modifiée le: {openNote.updated_at ? new Date(openNote.updated_at).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'N/A'}
{/* Save button */}
)}
{/* Right Column: Pomodoro Timer, Todos & RSS */}
{/* Pomodoro Timer */} {/* Todos & RSS Boxes side by side */}
{/* Todos Box */}
Tâches
Actives ({todos.filter(t => !t.completed).length}) Complétées ({todos.filter(t => t.completed).length})
{activeTodos.length > 0 ? ( paginatedActiveTodos.map(todo => (
todo.id && handleToggleTodo(todo.id)} className="h-4 w-4" /> {todo.text}
)) ) : (

Aucune tâche active

)}
{totalActiveTodosPages > 1 && (
{todosActivePage + 1}/{totalActiveTodosPages}
)}
{completedTodos.length > 0 ? ( paginatedCompletedTodos.map(todo => (
todo.id && handleToggleTodo(todo.id)} className="h-4 w-4" /> {todo.text} {todo.priority && ( )}
)) ) : (

Aucune tâche complétée

)}
{totalCompletedTodosPages > 1 && (
{todosCompletedPage + 1}/{totalCompletedTodosPages}
)}
{/* RSS Box */}
Flux RSS
{rssArticles.length > 0 ? ( paginatedRssArticles.map(article => (
article.link && window.open(article.link, '_blank')} >

{article.title}

{article.description && (

{article.description.replace(/<[^>]*>/g, '')}

)}
)) ) : (

Aucun article

)}
{totalRssPages > 1 && (
{rssPage + 1}/{totalRssPages}
)}
{/* Admin Modal */} Administration Gérez les utilisateurs et les paramètres de l'application Utilisateurs Flux RSS Google Calendar OpenRouter Paramètres
{users.map(u => (
{u.username.charAt(0).toUpperCase()}

{u.username}

{u.is_admin ? "Administrateur" : "Utilisateur"}

{u.id !== 1 && ( )}
))}
{rssFeeds.map(feed => (

{feed.title}

{feed.url}

))} {rssFeeds.length === 0 && (

Aucun flux RSS configuré

)}
Configuration Google Calendar OAuth 2.0
setSettings({ ...settings, app_url: e.target.value })} />

URL de votre site (utilisée pour les redirections OAuth)

setSettings({ ...settings, google_client_id: e.target.value })} />
setSettings({ ...settings, google_client_secret: e.target.value })} />

Créez un projet OAuth 2.0 sur{' '} Google Cloud Console {' '}et activez l'API Google Calendar.

⚠️ IMPORTANT : URI de redirection autorisée

Ajoutez cette URL exacte dans la console Google Cloud (OAuth 2.0 Client IDs → URIs de redirection autorisées) :

{settings.app_url || window.location.origin}/api/calendar/oauth-callback
{settings.google_client_id && settings.google_client_secret && ( <>

Statut de connexion

{calendarAuthStatus.isAuthenticated && !calendarAuthStatus.isExpired ? (
Connecté à Google Calendar (OAuth 2.0)
) : (
Non connecté
)}
{calendarAuthStatus.needsReauth ? ( ) : ( <> )}
)}
Configuration OpenRouter
setSettings({ ...settings, openrouter_api_key: e.target.value })} />

Obtenez votre clé API sur{' '} openrouter.ai

Paramètres généraux
setSettings({ ...settings, rss_refresh_interval: parseInt(e.target.value) })} />

Zone de danger / Maintenance

Utilisez ces options uniquement si vous rencontrez des problèmes spécifiques.

{/* Modals */} setDeleteUserModal({ open })} title="Supprimer l'utilisateur" description="Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible." onConfirm={confirmDeleteUser} confirmText="Supprimer" variant="destructive" /> setChangePasswordModal({ open })} title="Changer le mot de passe" label="Nouveau mot de passe" placeholder="Entrez le nouveau mot de passe..." onConfirm={confirmChangePassword} confirmText="Modifier" type="password" /> { if (openNote?.id) { const newTodo = await NotesService.addTodo(openNote.id, text); if (newTodo) { const updatedTodos = [...(openNote.todos || []), newTodo]; setOpenNote({ ...openNote, todos: updatedTodos }); setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note)); } } }} confirmText="Ajouter" /> {/* Template Selector */} {/* Add Event Modal */} Ajouter un rendez-vous Créer un événement dans votre Google Calendar
{ e.preventDefault(); const formData = new FormData(e.currentTarget); try { const reminderMinutes = formData.get('reminderMinutes') as string; const recurrenceValue = formData.get('recurrence') as string; const eventData = { title: formData.get('title') as string, description: formData.get('description') as string || undefined, startDateTime: toParisISO(formData.get('startDateTime') as string), endDateTime: toParisISO(formData.get('endDateTime') as string), location: formData.get('location') as string || undefined, attendees: (formData.get('attendees') as string || '').split(',').map(e => e.trim()).filter(e => e), reminders: reminderMinutes && reminderMinutes !== '0' ? [ { method: 'popup', minutes: parseInt(reminderMinutes) } ] : undefined, recurrence: recurrenceValue ? [recurrenceValue] : undefined, visibility: formData.get('visibility') as string || 'default', colorId: formData.get('colorId') as string || undefined }; await CalendarService.createEvent(eventData); showSuccess('Événement créé avec succès'); setAddEventModal(false); await loadCalendarEvents(); e.currentTarget.reset(); } catch (error: any) { showError(error.response?.data?.error || 'Erreur lors de la création de l\'événement'); } }} className="space-y-4">