mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
Problème identifié: - Le backend récupère les nouveaux articles (logs: "20 nouveaux") - Mais le frontend ne les affiche jamais - Les articles étaient chargés UNIQUEMENT au démarrage de la page - Pas de rafraîchissement automatique Solution: - Ajout d'un setInterval qui recharge les articles toutes les 2 minutes - Synchronisé avec le scheduler backend (aussi 2 minutes) - Nettoyage automatique à la destruction du composant Comportement maintenant: - Chargement initial au démarrage - Rafraîchissement auto toutes les 2 minutes - Les nouveaux articles apparaissent dans les 2 minutes max - Le refresh manuel fonctionne toujours (déjà implémenté) Script ajouté: - check-db-now.js: Vérifie le contenu exact de la DB RSS
2602 lines
107 KiB
TypeScript
2602 lines
107 KiB
TypeScript
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
|
||
} 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";
|
||
|
||
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 [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 [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 [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});
|
||
|
||
// 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' && settings.openrouter_api_key && openRouterModels.length === 0) {
|
||
loadOpenRouterModels();
|
||
}
|
||
}, [showAdmin, adminTab, settings.openrouter_api_key]);
|
||
|
||
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 = async () => {
|
||
try {
|
||
const newNote = await NotesService.createNote("Nouvelle note");
|
||
if (newNote) {
|
||
setNotes([newNote, ...notes]);
|
||
setOpenNote(newNote);
|
||
}
|
||
} 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 || []);
|
||
} 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 success = await NotesService.updateNote(updatedNote);
|
||
if (!success) {
|
||
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 {
|
||
await NotesService.updateNote(updatedNote);
|
||
// Update notes list silently after save
|
||
setNotes(prev => prev.map(note => note.id === updatedNote.id ? updatedNote : 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 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 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) {
|
||
setNoteTags([...noteTags, newTag]);
|
||
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) {
|
||
setNoteTags(noteTags.filter(t => t.id !== tagId));
|
||
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 = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
const filteredNotes = notes.filter(note =>
|
||
(showArchived ? note.archived : !note.archived) &&
|
||
(searchQuery === "" ||
|
||
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase())))
|
||
);
|
||
|
||
// 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]);
|
||
|
||
useEffect(() => {
|
||
setTodosActivePage(0);
|
||
setTodosCompletedPage(0);
|
||
}, [todos.length]);
|
||
|
||
useEffect(() => {
|
||
setRssPage(0);
|
||
}, [rssArticles.length]);
|
||
|
||
useEffect(() => {
|
||
setCalendarPage(0);
|
||
}, [calendarEvents.length]);
|
||
|
||
// 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' })}
|
||
</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>
|
||
|
||
{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();
|
||
|
||
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' : ''}`}
|
||
>
|
||
<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">
|
||
{isToday && (
|
||
<div className="h-2 w-2 rounded-full bg-blue-500 flex-shrink-0 animate-pulse" />
|
||
)}
|
||
<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' })}
|
||
{event.all_day ? ' - Toute la journée' : ` à ${startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`}
|
||
</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>
|
||
</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="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>
|
||
</div>
|
||
|
||
{/* Notes Grid - 1 column for better visibility */}
|
||
<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>
|
||
|
||
<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>
|
||
)}
|
||
</>
|
||
) : (
|
||
/* 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={() => openNote && handleUpdateNote({ archived: !openNote.archived })}
|
||
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/*"
|
||
className="hidden"
|
||
onChange={async (e) => {
|
||
if (e.target.files && e.target.files[0] && openNote?.id) {
|
||
try {
|
||
const image = await NotesService.uploadImage(openNote.id, e.target.files[0]);
|
||
if (image) {
|
||
const updatedImages = [...(openNote.images || []), image];
|
||
setOpenNote({ ...openNote, images: updatedImages });
|
||
await handleUpdateNote({ images: updatedImages });
|
||
showSuccess("Image ajoutée");
|
||
}
|
||
} catch (error) {
|
||
showError("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>
|
||
)}
|
||
|
||
{/* 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 () => {
|
||
const updatedTodos = [...(openNote.todos || [])];
|
||
updatedTodos[index] = { ...todo, completed: !todo.completed };
|
||
await handleUpdateNote({ todos: updatedTodos });
|
||
}}
|
||
/>
|
||
<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 () => {
|
||
const updatedTodos = [...(openNote.todos || [])];
|
||
updatedTodos.splice(index, 1);
|
||
await handleUpdateNote({ todos: updatedTodos });
|
||
}}
|
||
>
|
||
<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: 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 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>
|
||
<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>
|
||
|
||
{/* 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>
|
||
</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) {
|
||
const updatedTodos = [...(openNote.todos || []), { text, completed: false }];
|
||
await handleUpdateNote({ todos: updatedTodos });
|
||
}
|
||
}}
|
||
confirmText="Ajouter"
|
||
/>
|
||
|
||
<InputModal
|
||
open={addTagModal}
|
||
onOpenChange={setAddTagModal}
|
||
title="Nouveau tag"
|
||
label="Tag"
|
||
placeholder="Entrez le nom du tag..."
|
||
onConfirm={confirmAddTag}
|
||
confirmText="Ajouter"
|
||
/>
|
||
|
||
{/* 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: new Date(formData.get('startDateTime') as string).toISOString(),
|
||
endDateTime: new Date(formData.get('endDateTime') as string).toISOString(),
|
||
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={new Date(Date.now() + 60*60*1000).toISOString().slice(0, 16)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="endDateTime">Fin *</Label>
|
||
<Input
|
||
id="endDateTime"
|
||
name="endDateTime"
|
||
type="datetime-local"
|
||
required
|
||
defaultValue={new Date(Date.now() + 2*60*60*1000).toISOString().slice(0, 16)}
|
||
/>
|
||
</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: new Date(formData.get('startDateTime') as string).toISOString(),
|
||
endDateTime: new Date(formData.get('endDateTime') as string).toISOString(),
|
||
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 ? new Date(editEventModal.event.start_time).toISOString().slice(0, 16) : ''}
|
||
/>
|
||
</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 ? new Date(editEventModal.event.end_time).toISOString().slice(0, 16) : ''}
|
||
/>
|
||
</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 ? (
|
||
"Aucun modèle disponible"
|
||
) : 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>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>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Index;
|