From ece7232f0dfe256fb875ff1b0502d550482cee7f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Nov 2025 09:37:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Fix=20CRITIQUE:=20=C3=89criture=20fluide=20?= =?UTF-8?q?dans=20les=20notes=20r=C3=A9par=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RichTextEditor.tsx | 6 ++++-- src/pages/Index.tsx | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index a97e678..0510ba8 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -29,11 +29,13 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito const editorRef = useRef(null); const [selectedFormat, setSelectedFormat] = useState([]); + // Set initial content only once on mount useEffect(() => { - if (editorRef.current && editorRef.current.innerHTML !== content) { + if (editorRef.current) { editorRef.current.innerHTML = content; } - }, [content]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps - only run once on mount const execCommand = (command: string, value?: string) => { document.execCommand(command, false, value); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index c83967f..17af886 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -217,9 +217,9 @@ const Index = () => { const handleContentChange = useCallback((content: string) => { if (!openNote) return; - // Update local state immediately for responsiveness + // Don't update local state to avoid re-render during typing + // Just save to API in background const updatedNote = { ...openNote, content }; - setOpenNote(updatedNote); // Debounce the API call if (saveTimerRef.current) { @@ -229,11 +229,13 @@ const Index = () => { 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]); + }, [openNote, setNotes]); // Clean up timer on unmount useEffect(() => { @@ -625,6 +627,7 @@ const Index = () => { From 4425695b4f7de9a829f10f7a2fb2677282275ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Nov 2025 09:47:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Feature:=20Syst=C3=A8me=20de=20tags=20et=20?= =?UTF-8?q?liste=20automatique=20des=20mod=C3=A8les=20OpenRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajouts majeurs: - Système de tags complet pour les notes * Table note_tags en base de données avec indexes * API REST pour CRUD des tags (GET/POST/DELETE) * UI avec onglet Tags dans l'éditeur de notes * Badges cliquables avec icônes et suppression au survol * Modal pour ajouter des tags - Liste automatique des modèles OpenRouter * Nouvelle route API /api/openrouter/models * Cache des modèles avec rafraîchissement automatique (1h) * Dropdown dans l'admin avec tous les modèles disponibles * Bouton "Actualiser" pour forcer le refresh * Affichage du nom et description de chaque modèle * Fallback sur input manuel si pas de clé API Améliorations UI: - Ajout de dates de création et modification dans les notes - Affichage des dates dans les cartes et l'éditeur - Format français pour toutes les dates - Onglets passés de 4 à 5 colonnes pour inclure Tags - Animation de chargement sur le bouton refresh des modèles Architecture: - Nouveau service TagsService.ts pour la gestion des tags - Nouveau service OpenRouterService.ts pour l'API OpenRouter - Nouvelles routes openrouter.routes.js - Support Select component pour les dropdowns - Modals InputModal utilisés pour les tags --- config/database.js | 14 ++ routes/notes.routes.js | 72 ++++++++++ routes/openrouter.routes.js | 140 ++++++++++++++++++ server.js | 1 + src/pages/Index.tsx | 227 ++++++++++++++++++++++++++++-- src/services/OpenRouterService.ts | 59 ++++++++ src/services/TagsService.ts | 77 ++++++++++ 7 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 routes/openrouter.routes.js create mode 100644 src/services/OpenRouterService.ts create mode 100644 src/services/TagsService.ts diff --git a/config/database.js b/config/database.js index 816b635..c6bacbb 100644 --- a/config/database.js +++ b/config/database.js @@ -167,12 +167,26 @@ function initDatabase() { ) `); + // Table note_tags (tags pour les notes) + db.run(` + CREATE TABLE IF NOT EXISTS note_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + tag TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + UNIQUE(note_id, tag) + ) + `); + // Créer les index pour la performance db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_images ON note_images(note_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_files ON note_files(note_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_note_tags ON note_tags(note_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag)'); db.run('CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key)'); db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_feed ON rss_articles(feed_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_date ON rss_articles(pub_date)'); diff --git a/routes/notes.routes.js b/routes/notes.routes.js index 13a32e2..e3a7d96 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -823,5 +823,77 @@ router.delete('/files/:fileId', async (req, res) => { } }); +// ==================== TAGS ==================== + +/** + * GET /api/notes/:id/tags + * Récupérer les tags d'une note + */ +router.get('/:id/tags', async (req, res) => { + try { + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + const tags = await getAll('SELECT id, tag FROM note_tags WHERE note_id = ? ORDER BY tag ASC', [req.params.id]); + res.json(tags || []); + } catch (error) { + logger.error('Erreur lors de la récupération des tags:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/notes/:id/tags + * Ajouter un tag à une note + */ +router.post('/:id/tags', async (req, res) => { + try { + const { tag } = req.body; + if (!tag || !tag.trim()) { + return res.status(400).json({ error: 'Tag invalide' }); + } + + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + const result = await runQuery('INSERT INTO note_tags (note_id, tag) VALUES (?, ?)', [req.params.id, tag.trim().toLowerCase()]); + res.json({ id: result.id, tag: tag.trim().toLowerCase() }); + } catch (error) { + if (error.message && error.message.includes('UNIQUE constraint')) { + return res.status(409).json({ error: 'Ce tag existe déjà' }); + } + logger.error('Erreur lors de l\'ajout du tag:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * DELETE /api/notes/:noteId/tags/:tagId + * Supprimer un tag d'une note + */ +router.delete('/:noteId/tags/:tagId', async (req, res) => { + try { + const tag = await getOne(` + SELECT nt.id FROM note_tags nt + JOIN notes n ON nt.note_id = n.id + WHERE nt.id = ? AND nt.note_id = ? AND n.user_id = ? + `, [req.params.tagId, req.params.noteId, req.user.id]); + + if (!tag) { + return res.status(404).json({ error: 'Tag non trouvé' }); + } + + await runQuery('DELETE FROM note_tags WHERE id = ?', [req.params.tagId]); + res.json({ message: 'Tag supprimé' }); + } catch (error) { + logger.error('Erreur lors de la suppression du tag:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + module.exports = router; module.exports.searchNotes = searchNotes; diff --git a/routes/openrouter.routes.js b/routes/openrouter.routes.js new file mode 100644 index 0000000..faabd52 --- /dev/null +++ b/routes/openrouter.routes.js @@ -0,0 +1,140 @@ +// Routes pour OpenRouter API +const express = require('express'); +const router = express.Router(); +const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { getOne } = require('../config/database'); +const logger = require('../config/logger'); + +// Cache pour les modèles (rafraîchi toutes les heures) +let modelsCache = null; +let modelsCacheTime = 0; +const CACHE_DURATION = 60 * 60 * 1000; // 1 heure + +/** + * GET /api/openrouter/models + * Récupérer la liste des modèles disponibles depuis OpenRouter + */ +router.get('/models', requireAuth, requireAdmin, async (req, res) => { + try { + // Vérifier le cache + const now = Date.now(); + if (modelsCache && (now - modelsCacheTime) < CACHE_DURATION) { + return res.json(modelsCache); + } + + // Récupérer la clé API depuis les paramètres + const setting = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']); + + if (!setting || !setting.value) { + return res.status(400).json({ + error: 'Clé API OpenRouter non configurée', + models: [] + }); + } + + // Appeler l'API OpenRouter pour récupérer les modèles + const fetch = (await import('node-fetch')).default; + const response = await fetch('https://openrouter.ai/api/v1/models', { + headers: { + 'Authorization': `Bearer ${setting.value}`, + 'HTTP-Referer': process.env.APP_URL || 'http://localhost:5000', + 'X-Title': 'NoteFlow' + } + }); + + if (!response.ok) { + logger.error(`Erreur API OpenRouter: ${response.status} ${response.statusText}`); + return res.status(response.status).json({ + error: 'Erreur lors de la récupération des modèles', + models: [] + }); + } + + const data = await response.json(); + + // Extraire et formater les modèles + const models = (data.data || []).map(model => ({ + id: model.id, + name: model.name || model.id, + description: model.description || '', + context_length: model.context_length || 0, + pricing: model.pricing || {} + })); + + // Trier par nom + models.sort((a, b) => a.name.localeCompare(b.name)); + + // Mettre en cache + modelsCache = models; + modelsCacheTime = now; + + logger.info(`✓ ${models.length} modèles OpenRouter récupérés`); + res.json(models); + } catch (error) { + logger.error('Erreur lors de la récupération des modèles OpenRouter:', error); + res.status(500).json({ + error: 'Erreur serveur', + models: [] + }); + } +}); + +/** + * POST /api/openrouter/refresh-models + * Forcer le rafraîchissement du cache des modèles + */ +router.post('/refresh-models', requireAuth, requireAdmin, async (req, res) => { + try { + // Invalider le cache + modelsCache = null; + modelsCacheTime = 0; + + // Rediriger vers la route GET pour récupérer les modèles + const setting = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']); + + if (!setting || !setting.value) { + return res.status(400).json({ + error: 'Clé API OpenRouter non configurée', + models: [] + }); + } + + const fetch = (await import('node-fetch')).default; + const response = await fetch('https://openrouter.ai/api/v1/models', { + headers: { + 'Authorization': `Bearer ${setting.value}`, + 'HTTP-Referer': process.env.APP_URL || 'http://localhost:5000', + 'X-Title': 'NoteFlow' + } + }); + + if (!response.ok) { + return res.status(response.status).json({ + error: 'Erreur lors de la récupération des modèles', + models: [] + }); + } + + const data = await response.json(); + const models = (data.data || []).map(model => ({ + id: model.id, + name: model.name || model.id, + description: model.description || '', + context_length: model.context_length || 0, + pricing: model.pricing || {} + })); + + models.sort((a, b) => a.name.localeCompare(b.name)); + + modelsCache = models; + modelsCacheTime = Date.now(); + + logger.info(`✓ Cache des modèles OpenRouter rafraîchi (${models.length} modèles)`); + res.json({ message: 'Cache rafraîchi avec succès', models }); + } catch (error) { + logger.error('Erreur lors du rafraîchissement des modèles:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 33fdd24..ac9136d 100644 --- a/server.js +++ b/server.js @@ -87,6 +87,7 @@ app.use('/api/todos', require('./routes/todos.routes')); app.use('/api/settings', require('./routes/settings.routes')); app.use('/api/rss', require('./routes/rss.routes')); app.use('/api/calendar', require('./routes/calendar.routes')); +app.use('/api/openrouter', require('./routes/openrouter.routes')); // Route de recherche app.get('/api/search', require('./routes/notes.routes').searchNotes); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 17af886..ec9aa42 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -9,11 +9,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, 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 { RichTextEditor } from "@/components/RichTextEditor"; import { MadeWithDyad } from "@/components/made-with-dyad"; 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 + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -22,6 +23,8 @@ 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"; @@ -48,6 +51,9 @@ const Index = () => { const [settings, setSettings] = useState({}); const [adminTab, setAdminTab] = useState("users"); const [showArchived, setShowArchived] = useState(false); + const [noteTags, setNoteTags] = useState([]); + const [openRouterModels, setOpenRouterModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); const navigate = useNavigate(); // Modal states @@ -58,6 +64,7 @@ const Index = () => { 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); // Debounce timer for auto-save const saveTimerRef = useRef(null); @@ -155,6 +162,20 @@ const Index = () => { } }; + const loadOpenRouterModels = async () => { + if (user?.is_admin && settings.openrouter_api_key) { + setLoadingModels(true); + try { + const models = await OpenRouterService.getModels(); + setOpenRouterModels(models); + } catch (error) { + console.error("Erreur lors du chargement des modèles:", error); + } finally { + setLoadingModels(false); + } + } + }; + useEffect(() => { if (showAdmin) { loadUsers(); @@ -162,6 +183,12 @@ const Index = () => { } }, [showAdmin]); + useEffect(() => { + if (showAdmin && adminTab === 'openrouter' && settings.openrouter_api_key && openRouterModels.length === 0) { + loadOpenRouterModels(); + } + }, [showAdmin, adminTab, settings.openrouter_api_key]); + const handleLogout = () => { AuthService.logout(); navigate("/login"); @@ -179,12 +206,26 @@ const Index = () => { } }; + 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 @@ -376,6 +417,34 @@ const Index = () => { } }; + 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) => { if (e.target.files && e.target.files[0] && openNote?.id) { const formData = new FormData(); @@ -522,7 +591,7 @@ const Index = () => {

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

-
+
{note.todos && note.todos.length > 0 && ( @@ -542,6 +611,15 @@ const Index = () => { )}
+ {note.updated_at && ( +

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

+ )} )) @@ -603,7 +681,7 @@ const Index = () => { /> - + Contenu Tâches @@ -623,6 +701,12 @@ const Index = () => { {openNote.files.length} )} + + Tags + {noteTags.length > 0 && ( + {noteTags.length} + )} + @@ -801,7 +885,66 @@ const Index = () => { )}
+ + + + +
+ {noteTags.length > 0 ? ( + noteTags.map((tag) => ( + + + {tag.tag} + tag.id && handleDeleteTag(tag.id)} + /> + + )) + ) : ( +

Aucun tag

+ )} +
+
+ + {/* Note metadata */} + + +
+
+ Créée le: + {openNote.created_at ? new Date(openNote.created_at).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) : 'N/A'} +
+
+ Modifiée le: + {openNote.updated_at ? new Date(openNote.updated_at).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) : 'N/A'} +
+
+
+
)} @@ -1217,13 +1360,67 @@ const Index = () => {
- - setSettings({ ...settings, ai_model: e.target.value })} - /> +
+ + {settings.openrouter_api_key && ( + + )} +
+ {settings.openrouter_api_key && openRouterModels.length > 0 ? ( + + ) : ( + setSettings({ ...settings, ai_model: e.target.value })} + /> + )} +

+ {settings.openrouter_api_key + ? `${openRouterModels.length} modèles disponibles. Cliquez sur "Actualiser" pour charger la liste.` + : "Ajoutez d'abord une clé API pour voir les modèles disponibles."} +

@@ -1339,6 +1536,16 @@ const Index = () => { confirmText="Ajouter" /> + +
diff --git a/src/services/OpenRouterService.ts b/src/services/OpenRouterService.ts new file mode 100644 index 0000000..32ace61 --- /dev/null +++ b/src/services/OpenRouterService.ts @@ -0,0 +1,59 @@ +import AuthService from './AuthService'; + +export interface OpenRouterModel { + id: string; + name: string; + description: string; + context_length: number; + pricing: { + prompt?: string; + completion?: string; + }; +} + +class OpenRouterService { + /** + * Get available models from OpenRouter API + */ + async getModels(): Promise { + try { + const response = await fetch('/api/openrouter/models', { + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération des modèles'); + } + + const models = await response.json(); + return Array.isArray(models) ? models : []; + } catch (error) { + console.error('Erreur lors de la récupération des modèles OpenRouter:', error); + return []; + } + } + + /** + * Refresh models cache + */ + async refreshModels(): Promise { + try { + const response = await fetch('/api/openrouter/refresh-models', { + method: 'POST', + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error('Erreur lors du rafraîchissement des modèles'); + } + + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + } catch (error) { + console.error('Erreur lors du rafraîchissement des modèles:', error); + throw error; + } + } +} + +export default new OpenRouterService(); diff --git a/src/services/TagsService.ts b/src/services/TagsService.ts new file mode 100644 index 0000000..3e6f495 --- /dev/null +++ b/src/services/TagsService.ts @@ -0,0 +1,77 @@ +import AuthService from './AuthService'; + +export interface Tag { + id: number; + tag: string; +} + +class TagsService { + /** + * Get all tags for a note + */ + async getTags(noteId: number): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/tags`, { + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error('Erreur lors de la récupération des tags'); + } + + return await response.json(); + } catch (error) { + console.error('Erreur lors de la récupération des tags:', error); + throw error; + } + } + + /** + * Add a tag to a note + */ + async addTag(noteId: number, tag: string): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/tags`, { + method: 'POST', + headers: { + ...AuthService.getHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ tag }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Erreur lors de l\'ajout du tag'); + } + + return await response.json(); + } catch (error) { + console.error('Erreur lors de l\'ajout du tag:', error); + throw error; + } + } + + /** + * Delete a tag from a note + */ + async deleteTag(noteId: number, tagId: number): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/tags/${tagId}`, { + method: 'DELETE', + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error('Erreur lors de la suppression du tag'); + } + + return true; + } catch (error) { + console.error('Erreur lors de la suppression du tag:', error); + throw error; + } + } +} + +export default new TagsService();