Files
noteflow/src/pages/Index.tsx
T
Claude a3d8167a46 feat: add keyboard shortcuts and statistics dashboard
Keyboard Shortcuts:
- Create useKeyboardShortcuts hook with configurable shortcuts
- Support Ctrl/Cmd, Alt, Shift modifiers
- Add 7 global shortcuts (New note, Search, Quick capture, etc.)
- Create KeyboardShortcutsHelp modal with categorized shortcuts
- Show formatted shortcut keys (⌘ on Mac, Ctrl on Windows)
- Press ? to open shortcuts help

Statistics Dashboard:
- Create StatsDashboard component with productivity metrics
- Calculate productivity score (0-100) based on completion rate
- Show notes statistics (total, active, archived, priority)
- Display todos statistics with completion rate
- Track content stats (notes with todos/images/files, unique tags)
- Show recent activity (notes updated in last 7 days)
- Visual progress bar for productivity score
- Integrate dashboard in left column below calendar
2025-11-27 19:43:26 +00:00

3159 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<UserType | null>(null);
const [showAdmin, setShowAdmin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [notes, setNotes] = useState<Note[]>([]);
const [openNote, setOpenNote] = useState<Note | null>(null);
const [users, setUsers] = useState<UserType[]>([]);
const [todos, setTodos] = useState<Todo[]>([]);
const [rssFeeds, setRssFeeds] = useState<RssFeed[]>([]);
const [rssArticles, setRssArticles] = useState<RssArticle[]>([]);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [settings, setSettings] = useState<AppSettings>({});
const [adminTab, setAdminTab] = useState("users");
const [showArchived, setShowArchived] = useState(false);
const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list');
const [noteTags, setNoteTags] = useState<Tag[]>([]);
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [currentDateTime, setCurrentDateTime] = useState(new Date());
const [upcomingAlert, setUpcomingAlert] = useState<CalendarEvent | null>(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<string | null>(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<Array<{ role: string; content: string }>>([]);
const [chatInput, setChatInput] = useState("");
const [chatLoading, setChatLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<string>("");
const [defaultModel, setDefaultModel] = useState<string>("");
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
// Debounce timer for auto-save
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
const chatEndRef = useRef<HTMLDivElement>(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<Note>) => {
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<HTMLInputElement>) => {
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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Top Navigation */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-30">
{/* Alert bar for upcoming events */}
{upcomingAlert && (
<div className="bg-red-500 text-white px-8 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
<span className="font-medium">
Rendez-vous dans moins de 30 minutes : {upcomingAlert.title}
</span>
<span className="text-sm opacity-90">
à {new Date(upcomingAlert.start_time).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-red-600"
onClick={() => upcomingAlert.html_link && window.open(upcomingAlert.html_link, '_blank')}
>
Voir
</Button>
</div>
)}
<div className="max-w-[1920px] mx-auto px-8">
<div className="grid grid-cols-3 h-16 items-center">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<FileText className="h-5 w-5 text-primary" />
</div>
<span className="text-2xl font-semibold">NoteFlow</span>
</div>
<div className="flex flex-col items-center text-center text-sm text-muted-foreground">
<div className="font-medium">
{currentDateTime.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
<div className="text-lg font-mono">
{currentDateTime.toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
</div>
<div className="flex items-center gap-6 justify-end">
<Button onClick={handleCreateNote} size="lg" className="gap-2">
<PlusCircle className="h-5 w-5" />
Nouvelle Note
</Button>
<ModeToggle />
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-2">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-primary/10 text-primary">
{user.username ? user.username.charAt(0).toUpperCase() : "U"}
</AvatarFallback>
</Avatar>
<span className="font-medium">{user.username}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Mon compte</DropdownMenuLabel>
<DropdownMenuSeparator />
{user.is_admin && (
<DropdownMenuItem onClick={() => setShowAdmin(true)}>
<Settings className="mr-2 h-4 w-4" />
<span>Administration</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Déconnexion</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<div className="max-w-[1920px] mx-auto px-8 py-8">
{/* Three Column Layout: Calendar Left | Notes Middle | Todo+RSS Right */}
<div className="grid grid-cols-[300px,1fr,760px] gap-6">
{/* Left Column: Calendar */}
<div className="space-y-6">
<Card className="shadow-lg">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<CalendarIcon className="h-6 w-6" />
Rendez-vous
</CardTitle>
<div className="flex gap-1.5">
<Button size="sm" variant="outline" onClick={() => setAddEventModal(true)}>
<Plus className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
const result = await CalendarService.sync();
showSuccess(`${result.syncedCount} événements synchronisés`);
await loadCalendarEvents();
} catch (error) {
showError("Erreur lors de la synchronisation");
}
}}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[calc(100vh-250px)] overflow-y-auto">
{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 (
<div
key={event.id}
className={`p-3 border rounded-lg hover:bg-accent/50 transition-colors group relative ${isSoon ? 'border-red-500 bg-red-50 dark:bg-red-900/20' :
isToday ? 'bg-red-50/50 dark:bg-red-900/10' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => event.html_link && window.open(event.html_link, '_blank')}
>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium line-clamp-2 text-sm">{event.title}</h4>
</div>
<p className="text-xs text-muted-foreground">
{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' })}`}
</p>
{event.location && (
<p className="text-xs text-muted-foreground mt-1 truncate">
📍 {event.location}
</p>
)}
{isSoon && (
<Badge variant="destructive" className="mt-1 text-xs">Dans moins de 30 min</Badge>
)}
</div>
<div className="flex gap-1 items-start">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setEditEventModal({ open: true, event });
}}
>
<Edit className="h-3 w-3" />
</Button>
<ExternalLink
className="h-3 w-3 text-muted-foreground flex-shrink-0 mt-1 cursor-pointer"
onClick={() => event.html_link && window.open(event.html_link, '_blank')}
/>
</div>
</div>
</div>
);
})
) : (
<p className="text-center text-muted-foreground py-8 text-sm">
Aucun rendez-vous à venir
<br />
<Button
variant="link"
className="mt-2 text-xs"
onClick={() => setShowAdmin(true)}
>
Configurer Google Calendar
</Button>
</p>
)}
</div>
{totalCalendarPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => setCalendarPage(p => Math.max(0, p - 1))}
disabled={calendarPage === 0}
>
‹
</Button>
<span className="text-xs text-muted-foreground">
{calendarPage + 1}/{totalCalendarPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCalendarPage(p => Math.min(totalCalendarPages - 1, p + 1))}
disabled={calendarPage === totalCalendarPages - 1}
>
›
</Button>
</div>
)}
</CardContent>
</Card>
{/* Statistics Dashboard */}
<StatsDashboard notes={notes} todos={todos} />
</div>
{/* Middle Column: Notes or Open Note */}
<div className="space-y-6">
{!openNote ? (
<>
{/* Notes Grid Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-semibold">Mes Notes</h2>
<div className="flex gap-2">
<Button
variant={!showArchived ? "default" : "outline"}
onClick={() => setShowArchived(false)}
>
Actives ({notes.filter(n => !n.archived).length})
</Button>
<Button
variant={showArchived ? "default" : "outline"}
onClick={() => setShowArchived(true)}
>
Archivées ({notes.filter(n => n.archived).length})
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher dans les notes..."
className="pl-10 h-12 text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<AdvancedSearch
filters={searchFilters}
onFiltersChange={setSearchFilters}
availableTags={availableTags}
/>
{/* View Mode Toggle */}
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={notesViewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('list')}
className="gap-2"
>
<List className="h-4 w-4" />
Liste
</Button>
<Button
variant={notesViewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('kanban')}
className="gap-2"
>
<LayoutGrid className="h-4 w-4" />
Kanban
</Button>
</div>
</div>
</div>
{/* Notes View - List or Kanban */}
{notesViewMode === 'list' ? (
<>
<div className="grid grid-cols-1 gap-4">
{filteredNotes.length > 0 ? (
paginatedNotes.map(note => (
<Card
key={note.id}
className="cursor-pointer hover:shadow-md transition-all group relative"
onClick={() => handleOpenNote(note)}
>
<CardContent className="p-4">
{/* Priority icon */}
<Button
variant="ghost"
size="icon"
className={`absolute top-2 right-2 h-6 w-6 transition-opacity ${note.priority ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
const newPriority = !note.priority;
if (note.id) {
NotesService.togglePriority(note.id, newPriority);
setNotes(notes.map(n => n.id === note.id ? { ...n, priority: newPriority } : n));
}
}}
>
{note.priority ? (
<span className="text-red-500 text-lg font-bold">!</span>
) : (
<span className="text-muted-foreground text-lg">!</span>
)}
</Button>
{/* Archive icon */}
<Button
variant="ghost"
size="icon"
className={`absolute top-2 right-10 h-6 w-6 transition-opacity ${note.archived ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={async (e) => {
e.stopPropagation();
if (note.id) {
const newArchived = !note.archived;
const success = await NotesService.archiveNote(note.id, newArchived);
if (success) {
setNotes(notes.map(n => n.id === note.id ? { ...n, archived: newArchived } : n));
}
}
}}
title={note.archived ? "Désarchiver" : "Archiver"}
>
<Archive className="h-4 w-4 text-muted-foreground" />
</Button>
<h3 className="font-semibold text-base mb-2 line-clamp-1 pr-8">
{note.title || "Sans titre"}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
</p>
<div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && (
<Badge variant="secondary">
<CheckSquare className="h-3 w-3 mr-1" />
{note.todos.length}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
note.tags.map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
<TagIcon className="h-3 w-3 mr-1" />
{tag.name}
</Badge>
))
)}
</div>
{note.updated_at && (
<p className="text-xs text-muted-foreground">
Modifiée le {new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</p>
)}
</CardContent>
</Card>
))
) : (
<div className="col-span-1 text-center py-16">
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground text-lg mb-4">
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNote} size="lg">
<Plus className="h-5 w-5 mr-2" />
Créer votre première note
</Button>
)}
</div>
)}
</div>
{/* Pagination pour les notes */}
{totalNotesPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setNotesPage(p => Math.max(0, p - 1))}
disabled={notesPage === 0}
>
Précédent
</Button>
<span className="text-sm text-muted-foreground">
Page {notesPage + 1} sur {totalNotesPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setNotesPage(p => Math.min(totalNotesPages - 1, p + 1))}
disabled={notesPage >= totalNotesPages - 1}
>
Suivant
</Button>
</div>
)}
</>
) : (
/* Kanban View */
<KanbanBoard
notes={filteredNotes}
onNoteClick={handleOpenNote}
onCreateNote={(columnId) => {
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 */
<div className="space-y-4">
<div className="flex items-center justify-between">
<div></div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => {
if (openNote) {
downloadNoteAsMarkdown(openNote);
showSuccess("Note exportée en Markdown");
}
}}
title="Exporter en Markdown"
>
<FileDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={async () => {
if (openNote?.id) {
const newArchived = !openNote.archived;
const success = await NotesService.archiveNote(openNote.id, newArchived);
if (success) {
const updatedNote = { ...openNote, archived: newArchived };
setOpenNote(updatedNote);
setNotes(notes.map(n => n.id === openNote.id ? updatedNote : n));
}
}
}}
title={openNote?.archived ? "Désarchiver" : "Archiver"}
>
<Archive className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setDeleteNoteModal(true)}
title="Supprimer"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<Input
type="text"
placeholder="Titre de la note"
className="text-2xl font-semibold border-none shadow-none h-auto px-0 focus-visible:ring-0"
value={openNote?.title || ""}
onChange={(e) => handleUpdateNote({ title: e.target.value })}
/>
{/* Action buttons */}
<div className="flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => setAddNoteTodoModal(true)}
className="gap-2"
>
<CheckSquare className="h-4 w-4" />
Ajouter une tâche
</Button>
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById('image-upload')?.click()}
className="gap-2"
>
<ImageIcon className="h-4 w-4" />
Ajouter une image
</Button>
<input
id="image-upload"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={async (e) => {
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 = '';
}
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => document.getElementById('file-upload')?.click()}
className="gap-2"
>
<Paperclip className="h-4 w-4" />
Ajouter un fichier
</Button>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileUpload}
/>
<Button
variant="outline"
size="sm"
onClick={() => setAddTagModal(true)}
className="gap-2"
>
<TagIcon className="h-4 w-4" />
Ajouter un tag
</Button>
</div>
{/* Tags */}
{noteTags.length > 0 && (
<div className="flex gap-2 flex-wrap">
{noteTags.map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="text-sm px-2 py-1 gap-1 cursor-pointer hover:bg-destructive hover:text-destructive-foreground transition-colors group"
>
<TagIcon className="h-3 w-3" />
{tag.tag}
<X
className="h-3 w-3 opacity-50 group-hover:opacity-100"
onClick={() => tag.id && handleDeleteTag(tag.id)}
/>
</Badge>
))}
</div>
)}
{/* Smart Tag Suggestions */}
{openNote && (() => {
const existingTags = noteTags.map(t => t.tag);
const suggestions = getSmartTagSuggestions(openNote, notes, existingTags);
return suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Suggestions de tags
</p>
<div className="flex gap-2 flex-wrap">
{suggestions.map((suggestion) => (
<Badge
key={suggestion}
variant="outline"
className="text-xs px-2 py-1 gap-1 cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => confirmAddTag(suggestion)}
>
<Plus className="h-3 w-3" />
{suggestion}
</Badge>
))}
</div>
</div>
);
})()}
{/* Todos */}
{openNote.todos && openNote.todos.length > 0 && (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">todo list:</div>
<Card>
<CardContent className="p-4">
<div className="space-y-2">
{openNote.todos.map((todo, index) => (
<div key={index} className="flex items-center gap-3 p-2 border rounded">
<Checkbox
checked={todo.completed}
onCheckedChange={async () => {
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));
}
}}
/>
<span className={todo.completed ? "line-through text-muted-foreground flex-1 text-sm" : "flex-1 text-sm"}>
{todo.text}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={async () => {
if (!todo.id) return;
const success = await NotesService.deleteTodo(todo.id);
if (success) {
const updatedTodos = openNote.todos.filter((_, i) => i !== index);
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
showSuccess("Tâche supprimée");
}
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</>
)}
{/* Images in thumbnails */}
{openNote.images && openNote.images.length > 0 && (
<div className="grid grid-cols-4 gap-3">
{openNote.images.map((image, index) => (
<div key={index} className="relative group">
<img
src={`/uploads/images/${image.filename}`}
alt={image.original_name}
className="w-full h-24 object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setSelectedImage(`/uploads/images/${image.filename}`)}
/>
<Button
variant="destructive"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={async () => {
if (image.id && openNote.id) {
try {
const success = await NotesService.deleteImage(openNote.id, image.id);
if (success) {
const updatedImages = (openNote.images || []).filter((_, i) => i !== index);
await handleUpdateNote({ images: updatedImages });
showSuccess("Image supprimée");
}
} catch (error) {
showError("Erreur lors de la suppression");
}
}
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Files */}
{openNote.files && openNote.files.length > 0 && (
<>
<div className="text-sm font-medium text-muted-foreground mb-2">
Fichiers divers : Fichier(s)
</div>
<Card>
<CardContent className="p-4">
<div className="space-y-2">
{openNote.files.map((file: any, index: number) => (
<div key={index} className="flex items-center gap-3 p-2 border rounded group">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-sm">{file.original_name || file.filename}</p>
{file.size && (
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(2)} KB
</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => window.open(`/uploads/files/${file.filename}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-7 w-7"
onClick={async () => {
if (file.id && openNote.id) {
try {
const response = await fetch(`/api/notes/${openNote.id}/files/${file.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${AuthService.getToken()}`
}
});
if (response.ok) {
const updatedFiles = (openNote.files || []).filter((_, i) => i !== index);
await handleUpdateNote({ files: updatedFiles });
showSuccess("Fichier supprimé");
}
} catch (error) {
showError("Erreur lors de la suppression");
}
}
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</>
)}
{/* Rich text editor */}
<RichTextEditor
content={openNote.content || ""}
onChange={handleContentChange}
/>
{/* Note metadata */}
<Card className="mt-4">
<CardContent className="p-4">
<div className="text-sm text-muted-foreground space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">Créée le:</span>
<span>{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'}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Modifiée le:</span>
<span>{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'}</span>
</div>
</div>
</CardContent>
</Card>
{/* Save button */}
<div className="flex justify-end mt-6">
<Button
onClick={handleCloseNote}
className="gap-2"
>
Enregistrer
</Button>
</div>
</div>
)}
</div>
{/* Right Column: Pomodoro Timer, Todos & RSS */}
<div className="space-y-6">
{/* Pomodoro Timer */}
<PomodoroTimer />
{/* Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Todos Box */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Tâches
</CardTitle>
<Button size="sm" variant="outline" onClick={() => setAddTodoModal(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="active" className="w-full">
<TabsList className="grid grid-cols-2 w-full mb-3">
<TabsTrigger value="active" className="text-xs">Actives ({todos.filter(t => !t.completed).length})</TabsTrigger>
<TabsTrigger value="completed" className="text-xs">Complétées ({todos.filter(t => t.completed).length})</TabsTrigger>
</TabsList>
<TabsContent value="active" className="mt-0">
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
{activeTodos.length > 0 ? (
paginatedActiveTodos.map(todo => (
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
<Checkbox
checked={false}
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
className="h-4 w-4"
/>
<span className="text-sm flex-1 leading-snug">
{todo.text}
</span>
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 transition-all ${todo.in_progress ? 'text-red-500 opacity-100' : 'text-muted-foreground opacity-0 group-hover:opacity-100'}`}
onClick={() => todo.id && handleToggleTodoInProgress(todo.id)}
title={todo.in_progress ? "Marquer comme non commencé" : "Marquer comme en cours"}
>
<Activity className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 ${todo.priority ? '' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}
onClick={() => todo.id && handleToggleTodoPriority(todo.id)}
title={todo.priority ? "Retirer la priorité" : "Marquer comme prioritaire"}
>
<Star className={`h-3.5 w-3.5 ${todo.priority ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => todo.id && handleDeleteTodo(todo.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche active</p>
)}
</div>
{totalActiveTodosPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => setTodosActivePage(p => Math.max(0, p - 1))}
disabled={todosActivePage === 0}
>
‹
</Button>
<span className="text-xs text-muted-foreground">
{todosActivePage + 1}/{totalActiveTodosPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setTodosActivePage(p => Math.min(totalActiveTodosPages - 1, p + 1))}
disabled={todosActivePage >= totalActiveTodosPages - 1}
>
›
</Button>
</div>
)}
</TabsContent>
<TabsContent value="completed" className="mt-0">
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
{completedTodos.length > 0 ? (
paginatedCompletedTodos.map(todo => (
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
<Checkbox
checked={true}
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
className="h-4 w-4"
/>
<span className="text-sm flex-1 line-through text-muted-foreground leading-snug">
{todo.text}
</span>
{todo.priority && (
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400 opacity-50" />
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => todo.id && handleDeleteTodo(todo.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))
) : (
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche complétée</p>
)}
</div>
{totalCompletedTodosPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => setTodosCompletedPage(p => Math.max(0, p - 1))}
disabled={todosCompletedPage === 0}
>
‹
</Button>
<span className="text-xs text-muted-foreground">
{todosCompletedPage + 1}/{totalCompletedTodosPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setTodosCompletedPage(p => Math.min(totalCompletedTodosPages - 1, p + 1))}
disabled={todosCompletedPage >= totalCompletedTodosPages - 1}
>
›
</Button>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* RSS Box */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-xl flex items-center gap-2 flex-shrink-0">
<Rss className="h-5 w-5" />
Flux RSS
</CardTitle>
<div className="flex gap-1.5 flex-shrink-0">
<Button size="sm" variant="outline" onClick={handleRefreshRss}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => setAddRssFeedModal(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[calc(100vh-500px)] overflow-y-auto">
{rssArticles.length > 0 ? (
paginatedRssArticles.map(article => (
<div
key={article.id}
className="p-2.5 border rounded hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => article.link && window.open(article.link, '_blank')}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">{article.title}</h4>
{article.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{article.description.replace(/<[^>]*>/g, '')}
</p>
)}
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground text-sm py-6">
Aucun article
<br />
<Button
variant="link"
className="mt-2 text-xs"
onClick={() => setAddRssFeedModal(true)}
>
Ajouter un flux RSS
</Button>
</p>
)}
</div>
{totalRssPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => setRssPage(p => Math.max(0, p - 1))}
disabled={rssPage === 0}
>
‹
</Button>
<span className="text-xs text-muted-foreground">
{rssPage + 1}/{totalRssPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setRssPage(p => Math.min(totalRssPages - 1, p + 1))}
disabled={rssPage >= totalRssPages - 1}
>
›
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
{/* Admin Modal */}
<Dialog open={showAdmin} onOpenChange={setShowAdmin}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Administration</DialogTitle>
<DialogDescription>
Gérez les utilisateurs et les paramètres de l'application
</DialogDescription>
</DialogHeader>
<Tabs value={adminTab} onValueChange={setAdminTab}>
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
<TabsTrigger value="rss">Flux RSS</TabsTrigger>
<TabsTrigger value="calendar">Google Calendar</TabsTrigger>
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
<TabsTrigger value="settings">Paramètres</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-4 space-y-4">
<Button
className="flex items-center gap-2"
onClick={() => setAddUserModal(true)}
>
<User className="h-4 w-4" />
Ajouter un utilisateur
</Button>
<div className="space-y-2">
{users.map(u => (
<Card key={u.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{u.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{u.username}</p>
<p className="text-sm text-muted-foreground">
{u.is_admin ? "Administrateur" : "Utilisateur"}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setChangePasswordModal({ open: true, userId: u.id })}
>
<Key className="h-4 w-4 mr-2" />
Mot de passe
</Button>
{u.id !== 1 && (
<Button
variant="destructive"
size="sm"
onClick={() => setDeleteUserModal({ open: true, userId: u.id })}
>
Supprimer
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="rss" className="mt-4 space-y-4">
<Button
className="flex items-center gap-2"
onClick={() => setAddRssFeedModal(true)}
>
<Rss className="h-4 w-4" />
Ajouter un flux RSS
</Button>
<div className="space-y-2">
{rssFeeds.map(feed => (
<Card key={feed.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{feed.title}</p>
<p className="text-sm text-muted-foreground truncate">{feed.url}</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => feed.id && handleDeleteRssFeed(feed.id)}
>
Supprimer
</Button>
</CardContent>
</Card>
))}
{rssFeeds.length === 0 && (
<p className="text-center text-muted-foreground py-8">Aucun flux RSS configuré</p>
)}
</div>
</TabsContent>
<TabsContent value="calendar" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Configuration Google Calendar OAuth 2.0
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="app-url">URL du site</Label>
<Input
id="app-url"
type="url"
placeholder="https://votre-domaine.com"
value={settings.app_url || ''}
onChange={(e) => setSettings({ ...settings, app_url: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
URL de votre site (utilisée pour les redirections OAuth)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="google-client-id">Client ID Google</Label>
<Input
id="google-client-id"
type="text"
placeholder="123456789-abcdef.apps.googleusercontent.com"
value={settings.google_client_id || ''}
onChange={(e) => setSettings({ ...settings, google_client_id: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="google-client-secret">Client Secret Google</Label>
<Input
id="google-client-secret"
type="password"
placeholder="GOCSPX-..."
value={settings.google_client_secret || ''}
onChange={(e) => setSettings({ ...settings, google_client_secret: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
Créez un projet OAuth 2.0 sur{' '}
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Google Cloud Console
</a>
{' '}et activez l'API Google Calendar.
</p>
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm font-semibold text-yellow-900 dark:text-yellow-200 mb-1">
⚠️ IMPORTANT : URI de redirection autorisée
</p>
<p className="text-xs text-yellow-800 dark:text-yellow-300 mb-2">
Ajoutez cette URL exacte dans la console Google Cloud (OAuth 2.0 Client IDs → URIs de redirection autorisées) :
</p>
<code className="block p-2 bg-white dark:bg-gray-800 border border-yellow-300 dark:border-yellow-700 rounded text-xs font-mono break-all">
{settings.app_url || window.location.origin}/api/calendar/oauth-callback
</code>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveSettings} className="flex items-center gap-2">
<Key className="h-4 w-4" />
Enregistrer
</Button>
</div>
{settings.google_client_id && settings.google_client_secret && (
<>
<div className="border-t pt-4">
<h3 className="font-semibold mb-2">Statut de connexion</h3>
{calendarAuthStatus.isAuthenticated && !calendarAuthStatus.isExpired ? (
<div className="flex items-center gap-2 text-green-600">
<div className="h-2 w-2 rounded-full bg-green-600"></div>
<span>Connecté à Google Calendar (OAuth 2.0)</span>
</div>
) : (
<div className="flex items-center gap-2 text-yellow-600">
<div className="h-2 w-2 rounded-full bg-yellow-600"></div>
<span>Non connecté</span>
</div>
)}
</div>
<div className="flex gap-2">
{calendarAuthStatus.needsReauth ? (
<Button onClick={handleGoogleConnect} className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
Se connecter avec Google
</Button>
) : (
<>
<Button
onClick={async () => {
try {
const result = await CalendarService.sync();
showSuccess(`${result.syncedCount} événements synchronisés`);
await loadCalendarEvents();
} catch (error: any) {
if (error.response?.data?.needsReauth) {
showError("Session expirée. Veuillez vous reconnecter.");
checkCalendarAuthStatus();
} else {
showError("Erreur lors de la synchronisation.");
}
}
}}
className="flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
Synchroniser
</Button>
<Button
variant="outline"
onClick={handleGoogleDisconnect}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
Déconnecter
</Button>
</>
)}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="openrouter" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Configuration OpenRouter
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">Clé API OpenRouter</Label>
<Input
id="api-key"
type="password"
placeholder="sk-or-..."
value={settings.openrouter_api_key || ''}
onChange={(e) => setSettings({ ...settings, openrouter_api_key: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
Obtenez votre clé API sur{' '}
<a href="https://openrouter.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
openrouter.ai
</a>
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveSettings} className="flex items-center gap-2">
<Zap className="h-4 w-4" />
Enregistrer
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="mt-4">
<Card>
<CardHeader>
<CardTitle>Paramètres généraux</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="rss-interval">Intervalle de rafraîchissement RSS (minutes)</Label>
<Input
id="rss-interval"
type="number"
min="5"
placeholder="30"
value={settings.rss_refresh_interval || ''}
onChange={(e) => setSettings({ ...settings, rss_refresh_interval: parseInt(e.target.value) })}
/>
</div>
<Button onClick={handleSaveSettings}>
Enregistrer
</Button>
<div className="border-t pt-4 mt-4">
<h3 className="font-semibold mb-2 text-red-600">Zone de danger / Maintenance</h3>
<p className="text-sm text-muted-foreground mb-3">
Utilisez ces options uniquement si vous rencontrez des problèmes spécifiques.
</p>
<Button
variant="outline"
className="border-red-200 hover:bg-red-50 text-red-700"
onClick={async () => {
try {
const result = await AdminService.runInProgressMigration();
if (result.success) {
showSuccess(result.message);
} else {
showError(result.message);
}
} catch (error) {
showError("Erreur lors de la migration");
}
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Réparer la base de données (Migration)
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* Modals */}
<ConfirmModal
open={deleteNoteModal}
onOpenChange={setDeleteNoteModal}
title="Supprimer la note"
description="Êtes-vous sûr de vouloir supprimer cette note ? Cette action est irréversible."
onConfirm={confirmDeleteNote}
confirmText="Supprimer"
cancelText="Annuler"
variant="destructive"
/>
<InputModal
open={addTodoModal}
onOpenChange={setAddTodoModal}
title="Nouvelle tâche"
label="Tâche"
placeholder="Entrez la tâche..."
onConfirm={confirmAddTodo}
confirmText="Ajouter"
/>
<InputModal
open={addRssFeedModal}
onOpenChange={setAddRssFeedModal}
title="Ajouter un flux RSS"
label="URL du flux RSS"
placeholder="https://example.com/feed.xml"
onConfirm={confirmAddRssFeed}
confirmText="Ajouter"
type="url"
/>
<AddUserModal
open={addUserModal}
onOpenChange={setAddUserModal}
onConfirm={confirmAddUser}
/>
<ConfirmModal
open={deleteUserModal.open}
onOpenChange={(open) => 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"
/>
<InputModal
open={changePasswordModal.open}
onOpenChange={(open) => 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"
/>
<InputModal
open={addNoteTodoModal}
onOpenChange={setAddNoteTodoModal}
title="Nouvelle tâche"
label="Tâche"
placeholder="Entrez la tâche..."
onConfirm={async (text) => {
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"
/>
<InputModal
open={addTagModal}
onOpenChange={setAddTagModal}
title="Nouveau tag"
label="Tag"
placeholder="Entrez le nom du tag..."
onConfirm={confirmAddTag}
confirmText="Ajouter"
/>
{/* Template Selector */}
<TemplateSelector
open={templateSelectorOpen}
onOpenChange={setTemplateSelectorOpen}
onSelectTemplate={handleCreateNoteFromTemplate}
/>
{/* Add Event Modal */}
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Ajouter un rendez-vous</DialogTitle>
<DialogDescription>
Créer un événement dans votre Google Calendar
</DialogDescription>
</DialogHeader>
<form onSubmit={async (e) => {
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">
<div className="space-y-2">
<Label htmlFor="title">Titre *</Label>
<Input id="title" name="title" required placeholder="Réunion d'équipe" />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<textarea
id="description"
name="description"
className="w-full min-h-[80px] px-3 py-2 border border-input rounded-md bg-background"
placeholder="Détails de l'événement..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDateTime">Début *</Label>
<Input
id="startDateTime"
name="startDateTime"
type="datetime-local"
required
defaultValue={toLocalDateTimeString(new Date(Date.now() + 60 * 60 * 1000))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDateTime">Fin *</Label>
<Input
id="endDateTime"
name="endDateTime"
type="datetime-local"
required
defaultValue={toLocalDateTimeString(new Date(Date.now() + 2 * 60 * 60 * 1000))}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">Lieu</Label>
<Input id="location" name="location" placeholder="Salle de réunion A" />
</div>
<div className="space-y-2">
<Label htmlFor="attendees">Participants (emails séparés par des virgules)</Label>
<Input
id="attendees"
name="attendees"
type="text"
placeholder="email1@example.com, email2@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reminderMinutes">Rappel (minutes avant)</Label>
<select
id="reminderMinutes"
name="reminderMinutes"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue="30"
>
<option value="0">Aucun</option>
<option value="10">10 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 heure</option>
<option value="1440">1 jour</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="recurrence">Récurrence</Label>
<select
id="recurrence"
name="recurrence"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue=""
>
<option value="">Pas de récurrence</option>
<option value="RRULE:FREQ=DAILY">Tous les jours</option>
<option value="RRULE:FREQ=WEEKLY">Toutes les semaines</option>
<option value="RRULE:FREQ=MONTHLY">Tous les mois</option>
<option value="RRULE:FREQ=YEARLY">Tous les ans</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="visibility">Visibilité</Label>
<select
id="visibility"
name="visibility"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue="default"
>
<option value="default">Par défaut</option>
<option value="public">Public</option>
<option value="private">Privé</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="colorId">Couleur</Label>
<select
id="colorId"
name="colorId"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue=""
>
<option value="">Par défaut</option>
<option value="1">Lavande</option>
<option value="2">Sauge</option>
<option value="3">Raisin</option>
<option value="4">Flamant rose</option>
<option value="5">Banane</option>
<option value="6">Mandarine</option>
<option value="7">Paon</option>
<option value="8">Graphite</option>
<option value="9">Myrtille</option>
<option value="10">Basilic</option>
<option value="11">Tomate</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => setAddEventModal(false)}>
Annuler
</Button>
<Button type="submit">
Créer l'événement
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Edit Event Modal */}
<Dialog open={editEventModal.open} onOpenChange={(open) => setEditEventModal({ open, event: undefined })}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Modifier le rendez-vous</DialogTitle>
<DialogDescription>
Modifier l'événement dans votre Google Calendar
</DialogDescription>
</DialogHeader>
<form onSubmit={async (e) => {
e.preventDefault();
if (!editEventModal.event?.id) return;
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.updateEvent(editEventModal.event.id, eventData);
showSuccess('Événement mis à jour avec succès');
setEditEventModal({ open: false, event: undefined });
await loadCalendarEvents();
} catch (error: any) {
showError(error.response?.data?.error || 'Erreur lors de la mise à jour de l\'événement');
}
}} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-title">Titre *</Label>
<Input id="edit-title" name="title" required defaultValue={editEventModal.event?.title || ''} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<textarea
id="edit-description"
name="description"
className="w-full min-h-[80px] px-3 py-2 border border-input rounded-md bg-background"
defaultValue={editEventModal.event?.description || ''}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDateTime">Début *</Label>
<Input
id="edit-startDateTime"
name="startDateTime"
type="datetime-local"
required
defaultValue={editEventModal.event ? toLocalDateTimeString(editEventModal.event.start_time) : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDateTime">Fin *</Label>
<Input
id="edit-endDateTime"
name="endDateTime"
type="datetime-local"
required
defaultValue={editEventModal.event ? toLocalDateTimeString(editEventModal.event.end_time) : ''}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-location">Lieu</Label>
<Input id="edit-location" name="location" defaultValue={editEventModal.event?.location || ''} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-attendees">Participants (emails séparés par des virgules)</Label>
<Input
id="edit-attendees"
name="attendees"
type="text"
placeholder="email1@example.com, email2@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-reminderMinutes">Rappel (minutes avant)</Label>
<select
id="edit-reminderMinutes"
name="reminderMinutes"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue="30"
>
<option value="0">Aucun</option>
<option value="10">10 minutes</option>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 heure</option>
<option value="120">2 heures</option>
<option value="1440">1 jour</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-recurrence">Récurrence</Label>
<select
id="edit-recurrence"
name="recurrence"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue=""
>
<option value="">Aucune</option>
<option value="RRULE:FREQ=DAILY">Quotidienne</option>
<option value="RRULE:FREQ=WEEKLY">Hebdomadaire</option>
<option value="RRULE:FREQ=MONTHLY">Mensuelle</option>
<option value="RRULE:FREQ=YEARLY">Annuelle</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-visibility">Visibilité</Label>
<select
id="edit-visibility"
name="visibility"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue="default"
>
<option value="default">Par défaut</option>
<option value="public">Public</option>
<option value="private">Privé</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-colorId">Couleur</Label>
<select
id="edit-colorId"
name="colorId"
className="w-full h-10 px-3 py-2 border border-input rounded-md bg-background"
defaultValue=""
>
<option value="">Par défaut</option>
<option value="1">Lavande</option>
<option value="2">Sauge</option>
<option value="3">Raisin</option>
<option value="4">Flamant rose</option>
<option value="5">Banane</option>
<option value="6">Mandarine</option>
<option value="7">Paon</option>
<option value="8">Graphite</option>
<option value="9">Myrtille</option>
<option value="10">Basilic</option>
<option value="11">Tomate</option>
</select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => setEditEventModal({ open: false, event: undefined })}>
Annuler
</Button>
<Button type="submit">
Enregistrer les modifications
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* AI Chatbox */}
{!chatOpen ? (
<Button
className="fixed bottom-4 right-4 h-14 w-14 rounded-full shadow-lg"
onClick={() => setChatOpen(true)}
>
<MessageSquare className="h-6 w-6" />
</Button>
) : (
<Card className="fixed bottom-4 right-4 w-96 h-[600px] shadow-2xl flex flex-col">
<CardHeader className="pb-3 border-b">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Assistant IA
</CardTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={chatMessages.length === 0}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setChatOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Popover open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelSelectorOpen}
className="flex-1 justify-between text-xs h-9"
disabled={chatLoading || loadingModels}
>
<span className="truncate">
{loadingModels ? (
"Chargement des modèles..."
) : openRouterModels.length === 0 ? (
user?.is_admin ? "Configurer clé API OpenRouter" : "Aucun modèle IA configuré"
) : selectedModel ? (
<>
{openRouterModels.find(m => m.id === selectedModel)?.name || "Sélectionner un modèle"}
{defaultModel === selectedModel && (
<span className="ml-1 text-yellow-500">★</span>
)}
</>
) : (
"Sélectionner un modèle"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Rechercher un modèle..." className="h-9" />
<CommandList>
<CommandEmpty>
{openRouterModels.length === 0 && user?.is_admin ? (
<div className="p-4 text-center text-sm">
<p className="mb-2">Aucun modèle IA disponible</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setModelSelectorOpen(false);
setShowAdmin(true);
setAdminTab('openrouter');
}}
>
Configurer OpenRouter
</Button>
</div>
) : (
"Aucun modèle trouvé."
)}
</CommandEmpty>
<CommandGroup>
{openRouterModels.map((model) => (
<CommandItem
key={model.id}
value={model.name}
onSelect={() => {
setSelectedModel(model.id);
setModelSelectorOpen(false);
}}
className="text-xs"
>
<Check
className={`mr-2 h-4 w-4 ${selectedModel === model.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex-1 truncate">
{model.name}
</div>
{defaultModel === model.id && (
<span className="ml-1 text-yellow-500 text-sm">★</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedModel && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => handleSetDefaultModel(selectedModel)}
disabled={loadingModels}
title="Définir comme modèle par défaut"
>
{defaultModel === selectedModel ? (
<span className="text-yellow-500">★</span>
) : (
<span className="text-muted-foreground">☆</span>
)}
</Button>
)}
</div>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto p-4 space-y-3">
{chatMessages.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
Commencez une conversation avec l'IA
</div>
) : (
chatMessages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
{msg.content}
</div>
</div>
))
)}
{chatLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-3 py-2 text-sm">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce delay-100">●</span>
<span className="animate-bounce delay-200">●</span>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</CardContent>
<div className="border-t p-3">
<form
onSubmit={(e) => {
e.preventDefault();
handleSendMessage();
}}
className="flex gap-2"
>
<Input
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Posez une question..."
disabled={chatLoading || !selectedModel}
className="flex-1"
/>
<Button
type="submit"
size="icon"
disabled={chatLoading || !chatInput.trim() || !selectedModel}
>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</Card>
)}
{/* Image modal for fullscreen view */}
{selectedImage && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-7xl max-h-full">
<Button
variant="ghost"
size="icon"
className="absolute -top-12 right-0 text-white hover:bg-white/20"
onClick={() => setSelectedImage(null)}
>
<X className="h-6 w-6" />
</Button>
<img
src={selectedImage}
alt="Preview"
className="max-w-full max-h-[90vh] object-contain rounded-lg"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
open={showKeyboardHelp}
onOpenChange={setShowKeyboardHelp}
shortcuts={keyboardShortcuts}
/>
{/* Quick Capture Widget */}
<QuickCaptureWidget onNoteCaptured={loadNotes} />
</div>
);
};
export default Index;