From f8cef758ad9a34d7bf5fe72d34481647b8ed5198 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 10:40:43 +0000 Subject: [PATCH 01/55] =?UTF-8?q?Fix:=20Am=C3=A9lioration=20des=20logs=20e?= =?UTF-8?q?t=20gestion=20d'erreurs=20pour=20l'assistant=20IA=20OpenRouter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : - Erreur 502 (Bad Gateway) sur /api/openrouter/chat - Message d'erreur générique "Provider returned error" peu informatif - Difficile de diagnostiquer la cause réelle du problème Solution backend (routes/openrouter.routes.js) : 1. Ajout de logs détaillés à chaque étape : - Début de requête avec modèle et nombre de messages - Validation de la clé API - Requête envoyée à OpenRouter - Réponse reçue avec status HTTP - Contenu de la réponse ou erreur détaillée 2. Amélioration de la gestion des erreurs : - Extraction du message d'erreur d'OpenRouter - Logs structurés avec contexte complet - Messages d'erreur plus descriptifs Solution frontend (src/services/OpenRouterService.ts) : 1. Ajout de logs console à chaque étape 2. Messages d'erreur contextuels selon le code HTTP : - 400 : Requête invalide (message d'erreur API) - 401 : Clé API invalide - 402 : Crédits épuisés - 429 : Trop de requêtes - 502/503 : Service temporairement indisponible 3. Validation de la réponse : - Vérification que le contenu existe - Message d'erreur si réponse vide Résultat : - Logs détaillés backend + frontend pour diagnostic - Messages d'erreur clairs et actionnables pour l'utilisateur - Meilleure traçabilité des problèmes OpenRouter --- routes/openrouter.routes.js | 46 +++++++++++++++++++++++++------ src/services/OpenRouterService.ts | 37 ++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/routes/openrouter.routes.js b/routes/openrouter.routes.js index 6d3de69..ec05d24 100644 --- a/routes/openrouter.routes.js +++ b/routes/openrouter.routes.js @@ -143,7 +143,10 @@ router.post('/chat', authenticateToken, async (req, res) => { try { const { model, messages } = req.body; + logger.info(`[OPENROUTER CHAT] Début requête - model: "${model}", messages: ${messages?.length || 0}`); + if (!model || !messages || !Array.isArray(messages)) { + logger.warn('[OPENROUTER CHAT] Requête invalide - modèle ou messages manquants'); return res.status(400).json({ error: 'Modèle et messages requis' }); } @@ -151,11 +154,21 @@ router.post('/chat', authenticateToken, async (req, res) => { const setting = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_api_key']); if (!setting || !setting.value) { - return res.status(400).json({ error: 'Clé API OpenRouter non configurée' }); + logger.warn('[OPENROUTER CHAT] Clé API non configurée'); + return res.status(400).json({ error: 'Clé API OpenRouter non configurée. Allez dans Paramètres pour la configurer.' }); } + logger.info(`[OPENROUTER CHAT] Clé API trouvée, appel à OpenRouter...`); + // Appeler l'API OpenRouter const fetch = (await import('node-fetch')).default; + const requestBody = { + model: model, + messages: messages + }; + + logger.info(`[OPENROUTER CHAT] Requête OpenRouter: ${JSON.stringify({ model, messageCount: messages.length })}`); + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { @@ -164,25 +177,40 @@ router.post('/chat', authenticateToken, async (req, res) => { 'X-Title': 'NoteFlow', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: model, - messages: messages - }) + body: JSON.stringify(requestBody) }); + logger.info(`[OPENROUTER CHAT] Réponse reçue - status: ${response.status} ${response.statusText}`); + if (!response.ok) { const errorData = await response.json().catch(() => ({})); - logger.error(`Erreur API OpenRouter chat: ${response.status}`, errorData); + const errorMessage = errorData.error?.message || errorData.error || `Erreur ${response.status}: ${response.statusText}`; + + logger.error(`[OPENROUTER CHAT] Erreur API OpenRouter:`, { + status: response.status, + statusText: response.statusText, + error: errorData + }); + return res.status(response.status).json({ - error: errorData.error?.message || 'Erreur lors de la communication avec l\'IA' + error: errorMessage }); } const data = await response.json(); + logger.info(`[OPENROUTER CHAT] Succès - réponse générée (${data.choices?.[0]?.message?.content?.length || 0} caractères)`); + res.json(data); } catch (error) { - logger.error('Erreur lors du chat OpenRouter:', error); - res.status(500).json({ error: 'Erreur serveur' }); + logger.error('[OPENROUTER CHAT] Erreur exception:', { + message: error.message, + stack: error.stack, + name: error.name + }); + + res.status(500).json({ + error: `Erreur serveur: ${error.message || 'Erreur inconnue'}` + }); } }); diff --git a/src/services/OpenRouterService.ts b/src/services/OpenRouterService.ts index a402429..7250ddb 100644 --- a/src/services/OpenRouterService.ts +++ b/src/services/OpenRouterService.ts @@ -60,6 +60,8 @@ class OpenRouterService { */ async sendMessage(model: string, messages: Array<{ role: string; content: string }>): Promise { try { + console.log(`[OpenRouter] Envoi requête - model: ${model}, messages: ${messages.length}`); + const response = await fetch('/api/openrouter/chat', { method: 'POST', headers: { @@ -69,15 +71,42 @@ class OpenRouterService { body: JSON.stringify({ model, messages }) }); + console.log(`[OpenRouter] Réponse reçue - status: ${response.status}`); + if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Erreur inconnue' })); - throw new Error(error.error || 'Erreur lors de la communication avec l\'IA'); + const errorData = await response.json().catch(() => ({ error: 'Erreur inconnue' })); + const errorMessage = errorData.error || `Erreur HTTP ${response.status}`; + + console.error('[OpenRouter] Erreur:', errorMessage); + + // Messages d'erreur plus clairs selon le code HTTP + if (response.status === 400) { + throw new Error(errorMessage); + } else if (response.status === 401) { + throw new Error('Clé API invalide. Vérifiez la configuration dans Paramètres.'); + } else if (response.status === 402) { + throw new Error('Crédits OpenRouter épuisés. Rechargez votre compte.'); + } else if (response.status === 429) { + throw new Error('Trop de requêtes. Attendez quelques instants.'); + } else if (response.status === 502 || response.status === 503) { + throw new Error('Service OpenRouter temporairement indisponible. Réessayez dans quelques instants.'); + } else { + throw new Error(errorMessage); + } } const data = await response.json(); - return data.choices?.[0]?.message?.content || 'Pas de réponse'; + const content = data.choices?.[0]?.message?.content; + + if (!content) { + console.error('[OpenRouter] Réponse vide:', data); + throw new Error('Aucune réponse générée par le modèle'); + } + + console.log(`[OpenRouter] Succès - ${content.length} caractères`); + return content; } catch (error: any) { - console.error('Erreur lors de l\'envoi du message:', error); + console.error('[OpenRouter] Erreur exception:', error); throw error; } } From 75551be69d764e21261ee24804cfa3120626b934 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 08:43:46 +0000 Subject: [PATCH 02/55] fix: resolve note todos synchronization issue between mobile and desktop Fixed critical bug where todos added on mobile or desktop were being silently lost. Root cause: - Mobile and desktop were sending todos via the generic PUT /api/notes/:id endpoint - Backend ignored the 'todos' field in this endpoint (only processes title and content) - Users saw success messages but todos were never saved to database - On reload, todos disappeared Solution: - Added dedicated todo management methods to NotesService: * addTodo() - POST /api/notes/:id/todos * updateTodo() - PUT /api/notes/todos/:todoId * toggleTodo() - PUT /api/notes/todos/:todoId * deleteTodo() - DELETE /api/notes/todos/:todoId - Updated NoteDetailPage.tsx (mobile) to use proper API methods - Updated Index.tsx (desktop) to use proper API methods - Both versions now correctly persist todos to the database Files modified: - src/services/NotesService.ts - Added todo management methods - src/pages/mobile/NoteDetailPage.tsx - Fixed todo handlers - src/pages/Index.tsx - Fixed todo handlers in note detail view --- src/pages/Index.tsx | 32 ++++++++---- src/pages/mobile/NoteDetailPage.tsx | 40 ++++++++++----- src/services/NotesService.ts | 79 +++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 9a2eacf..62f9c7e 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1414,9 +1414,14 @@ const Index = () => { { - const updatedTodos = [...(openNote.todos || [])]; - updatedTodos[index] = { ...todo, completed: !todo.completed }; - await handleUpdateNote({ todos: updatedTodos }); + if (!todo.id) return; + const success = await NotesService.toggleTodo(todo.id, !todo.completed); + if (success) { + const updatedTodos = [...(openNote.todos || [])]; + updatedTodos[index] = { ...todo, completed: !todo.completed }; + setOpenNote({ ...openNote, todos: updatedTodos }); + setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note)); + } }} /> @@ -1427,9 +1432,14 @@ const Index = () => { size="icon" className="h-6 w-6" onClick={async () => { - const updatedTodos = [...(openNote.todos || [])]; - updatedTodos.splice(index, 1); - await handleUpdateNote({ todos: updatedTodos }); + if (!todo.id) return; + const success = await NotesService.deleteTodo(todo.id); + if (success) { + const updatedTodos = openNote.todos.filter((_, i) => i !== index); + setOpenNote({ ...openNote, todos: updatedTodos }); + setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note)); + showSuccess("Tâche supprimée"); + } }} > @@ -2215,9 +2225,13 @@ const Index = () => { label="Tâche" placeholder="Entrez la tâche..." onConfirm={async (text) => { - if (openNote) { - const updatedTodos = [...(openNote.todos || []), { text, completed: false }]; - await handleUpdateNote({ todos: updatedTodos }); + if (openNote?.id) { + const newTodo = await NotesService.addTodo(openNote.id, text); + if (newTodo) { + const updatedTodos = [...(openNote.todos || []), newTodo]; + setOpenNote({ ...openNote, todos: updatedTodos }); + setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note)); + } } }} confirmText="Ajouter" diff --git a/src/pages/mobile/NoteDetailPage.tsx b/src/pages/mobile/NoteDetailPage.tsx index 15bf8cd..b525f1a 100644 --- a/src/pages/mobile/NoteDetailPage.tsx +++ b/src/pages/mobile/NoteDetailPage.tsx @@ -80,28 +80,40 @@ export default function NoteDetailPage() { }; const handleAddTodo = async () => { - if (!note || !todoText.trim()) return; - const newTodo = { text: todoText, completed: false }; - const updatedTodos = [...(note.todos || []), newTodo]; - await updateNote({ todos: updatedTodos }); - setTodoText(""); - setAddTodoModal(false); - showSuccess("Tâche ajoutée"); + if (!note?.id || !todoText.trim()) return; + const newTodo = await NotesService.addTodo(note.id, todoText.trim()); + if (newTodo) { + const updatedTodos = [...(note.todos || []), newTodo]; + setNote({ ...note, todos: updatedTodos }); + setTodoText(""); + setAddTodoModal(false); + } }; const handleToggleTodo = async (index: number) => { if (!note?.todos) return; - const updatedTodos = [...note.todos]; - updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed }; - await updateNote({ todos: updatedTodos }); + const todo = note.todos[index]; + if (!todo.id) return; + + const success = await NotesService.toggleTodo(todo.id, !todo.completed); + if (success) { + const updatedTodos = [...note.todos]; + updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed }; + setNote({ ...note, todos: updatedTodos }); + } }; const handleDeleteTodo = async (index: number) => { if (!note?.todos) return; - const updatedTodos = [...note.todos]; - updatedTodos.splice(index, 1); - await updateNote({ todos: updatedTodos }); - showSuccess("Tâche supprimée"); + const todo = note.todos[index]; + if (!todo.id) return; + + const success = await NotesService.deleteTodo(todo.id); + if (success) { + const updatedTodos = note.todos.filter((_, i) => i !== index); + setNote({ ...note, todos: updatedTodos }); + showSuccess("Tâche supprimée"); + } }; const handleImageUpload = async (e: React.ChangeEvent) => { diff --git a/src/services/NotesService.ts b/src/services/NotesService.ts index c4d338a..d308bf3 100644 --- a/src/services/NotesService.ts +++ b/src/services/NotesService.ts @@ -219,6 +219,85 @@ class NotesService { return false; } } + + // Todo management methods + async addTodo(noteId: number, text: string): Promise { + try { + const response = await fetch(`/api/notes/${noteId}/todos`, { + method: "POST", + headers: AuthService.getHeaders(), + body: JSON.stringify({ text }) + }); + + if (!response.ok) { + throw new Error("Erreur lors de l'ajout de la tâche"); + } + + const result = await response.json(); + showSuccess("Tâche ajoutée"); + return result.todo || result; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + async updateTodo(todoId: number, updates: Partial): Promise { + try { + const response = await fetch(`/api/notes/todos/${todoId}`, { + method: "PUT", + headers: AuthService.getHeaders(), + body: JSON.stringify(updates) + }); + + if (!response.ok) { + throw new Error("Erreur lors de la mise à jour de la tâche"); + } + + const result = await response.json(); + return result.todo || result; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return null; + } + } + + async toggleTodo(todoId: number, completed: boolean): Promise { + try { + const response = await fetch(`/api/notes/todos/${todoId}`, { + method: "PUT", + headers: AuthService.getHeaders(), + body: JSON.stringify({ completed }) + }); + + if (!response.ok) { + throw new Error("Erreur lors de la mise à jour de la tâche"); + } + + return true; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return false; + } + } + + async deleteTodo(todoId: number): Promise { + try { + const response = await fetch(`/api/notes/todos/${todoId}`, { + method: "DELETE", + headers: AuthService.getHeaders() + }); + + if (!response.ok) { + throw new Error("Erreur lors de la suppression de la tâche"); + } + + return true; + } catch (error) { + showError(error instanceof Error ? error.message : "Erreur serveur"); + return false; + } + } } export default new NotesService(); \ No newline at end of file From 2b7d824c223e1ecad30be048f8a21fed8e6a8851 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 08:44:34 +0000 Subject: [PATCH 03/55] chore: update package-lock.json after npm install --- package-lock.json | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15c3881..c3b5d09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -138,7 +138,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3682,7 +3681,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3693,7 +3691,6 @@ "integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3704,7 +3701,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3761,7 +3757,6 @@ "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", @@ -4047,7 +4042,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4482,7 +4476,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5335,8 +5328,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -5537,7 +5529,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6992,7 +6983,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8226,7 +8216,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -8366,7 +8355,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8757,7 +8745,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8798,7 +8785,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9851,7 +9837,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10056,7 +10041,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10185,7 +10169,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10433,7 +10416,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -10525,7 +10507,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, From e420364b503831fc452bd7bd5956596746a2da69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 12:49:07 +0000 Subject: [PATCH 04/55] fix: correct SQL bug in todo creation query Fixed critical SQL type error in todo creation endpoint that prevented todos from being added to notes. Bug: - Line 417: Used FALSE (boolean) instead of 0 (integer) in COALESCE function - SQL: COALESCE(MAX(position), FALSE) + 1 - This caused a type mismatch error when inserting new todos Fix: - Changed to: COALESCE(MAX(position), 0) + 1 - Now correctly defaults to 0 when no todos exist for a note This fix resolves the issue where adding todos would fail silently or throw database errors. --- routes/notes.routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/notes.routes.js b/routes/notes.routes.js index 98a8977..14650e3 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -414,7 +414,7 @@ router.post('/:id/todos', const result = await runQuery(` INSERT INTO note_todos (note_id, text, position) - VALUES ($1, $2, (SELECT COALESCE(MAX(position), FALSE) + 1 FROM note_todos WHERE note_id = $3)) + VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $3)) RETURNING * `, [req.params.id, text, req.params.id]); From 185fe92b6a282d4a11e45ab5725d02b3520c2c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:00:38 +0000 Subject: [PATCH 05/55] fix: improve image upload error handling on mobile and desktop Fixed image upload issues by improving error handling and validation: Mobile (NoteDetailPage.tsx): - Added file size validation (5MB max) before upload - Added error message when upload returns null - Specified exact image types in accept attribute - Better user feedback for all error cases Desktop (Index.tsx): - Added file size validation (5MB max) before upload - Added error message when upload returns null - Specified exact image types in accept attribute - Removed unnecessary handleUpdateNote call (backend ignores images field) - Fixed state update to only update local state, not call backend Both versions now: - Show clear error messages to users when upload fails - Validate file size before attempting upload - Accept only supported formats: JPEG, PNG, WebP, GIF - Handle null responses from upload service properly This fixes the silent failure issue where images would not upload but no error was shown to the user. --- src/pages/Index.tsx | 18 +++++++++++++++--- src/pages/mobile/NoteDetailPage.tsx | 17 +++++++++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 62f9c7e..ef8238a 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1335,17 +1335,29 @@ const Index = () => { { if (e.target.files && e.target.files[0] && openNote?.id) { + const file = e.target.files[0]; + + // Vérifier la taille du fichier (5MB max) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + showError("L'image est trop grande (max 5MB)"); + e.target.value = ''; + return; + } + try { - const image = await NotesService.uploadImage(openNote.id, e.target.files[0]); + const image = await NotesService.uploadImage(openNote.id, file); if (image) { const updatedImages = [...(openNote.images || []), image]; setOpenNote({ ...openNote, images: updatedImages }); - await handleUpdateNote({ images: updatedImages }); + setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, images: updatedImages } : note)); showSuccess("Image ajoutée"); + } else { + showError("Erreur lors de l'upload de l'image"); } } catch (error) { showError("Erreur lors de l'upload de l'image"); diff --git a/src/pages/mobile/NoteDetailPage.tsx b/src/pages/mobile/NoteDetailPage.tsx index b525f1a..0e71128 100644 --- a/src/pages/mobile/NoteDetailPage.tsx +++ b/src/pages/mobile/NoteDetailPage.tsx @@ -118,12 +118,25 @@ export default function NoteDetailPage() { const handleImageUpload = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files[0] || !note?.id) return; + + const file = e.target.files[0]; + + // Vérifier la taille du fichier (5MB max) + const maxSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxSize) { + showError("L'image est trop grande (max 5MB)"); + e.target.value = ''; + return; + } + try { - const image = await NotesService.uploadImage(note.id, e.target.files[0]); + const image = await NotesService.uploadImage(note.id, file); if (image) { const updatedImages = [...(note.images || []), image]; setNote({ ...note, images: updatedImages }); showSuccess("Image ajoutée"); + } else { + showError("Erreur lors de l'upload de l'image"); } } catch (error) { showError("Erreur lors de l'upload"); @@ -228,7 +241,7 @@ export default function NoteDetailPage() { From cc635f786f05ebb10c3af604a9fa4fec5dc16f1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:14:37 +0000 Subject: [PATCH 06/55] feat: add automatic image compression before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added client-side image compression to improve upload speed and reduce storage. New Features: - Created imageCompression utility (src/utils/imageCompression.ts) * Automatically compresses images larger than 1MB * Resizes to max 1920x1080px while preserving aspect ratio * Uses 80% JPEG quality by default * Preserves PNG transparency when present * Falls back to 60% quality if still over 2MB Mobile (NoteDetailPage.tsx): - Integrated compression before upload - Shows compression stats to user (e.g., "5.2 MB → 1.1 MB") - Better error messages with specific details Desktop (Index.tsx): - Same compression integration as mobile - Consistent user experience across platforms Benefits: ✅ Users can upload large photos from cameras/phones ✅ Faster uploads (smaller files) ✅ Reduced server storage requirements ✅ Better mobile experience (photos often 5-10MB) ✅ Transparent to user (happens automatically) Technical Details: - Uses HTML5 Canvas API for processing - Client-side compression (no server load) - Maintains image quality while reducing size - Handles JPEG, PNG, WebP, and GIF formats - Smart transparency detection for PNGs --- src/pages/Index.tsx | 33 ++++-- src/pages/mobile/NoteDetailPage.tsx | 33 ++++-- src/utils/imageCompression.ts | 154 ++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/utils/imageCompression.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ef8238a..b553b3b 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -31,6 +31,7 @@ import { showError, showSuccess } from "@/utils/toast"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; +import { compressImage, formatFileSize } from "@/utils/imageCompression"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1339,18 +1340,28 @@ const Index = () => { className="hidden" onChange={async (e) => { if (e.target.files && e.target.files[0] && openNote?.id) { - const file = e.target.files[0]; - - // Vérifier la taille du fichier (5MB max) - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - showError("L'image est trop grande (max 5MB)"); - e.target.value = ''; - return; - } + const originalFile = e.target.files[0]; + const originalSize = formatFileSize(originalFile.size); try { - const image = await NotesService.uploadImage(openNote.id, file); + // Compresser l'image avant l'upload + const compressedFile = await compressImage(originalFile); + const compressedSize = formatFileSize(compressedFile.size); + + // Afficher un message si l'image a été compressée + if (compressedFile.size < originalFile.size) { + showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`); + } + + // Vérifier la taille finale (5MB max après compression) + const maxSize = 5 * 1024 * 1024; // 5MB + if (compressedFile.size > maxSize) { + showError("L'image est trop grande même après compression (max 5MB)"); + e.target.value = ''; + return; + } + + const image = await NotesService.uploadImage(openNote.id, compressedFile); if (image) { const updatedImages = [...(openNote.images || []), image]; setOpenNote({ ...openNote, images: updatedImages }); @@ -1360,7 +1371,7 @@ const Index = () => { showError("Erreur lors de l'upload de l'image"); } } catch (error) { - showError("Erreur lors de l'upload de l'image"); + showError(error instanceof Error ? error.message : "Erreur lors de l'upload de l'image"); } e.target.value = ''; } diff --git a/src/pages/mobile/NoteDetailPage.tsx b/src/pages/mobile/NoteDetailPage.tsx index 0e71128..2a51d4d 100644 --- a/src/pages/mobile/NoteDetailPage.tsx +++ b/src/pages/mobile/NoteDetailPage.tsx @@ -27,6 +27,7 @@ import { } from "@/components/ui/dropdown-menu"; import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; +import { compressImage, formatFileSize } from "@/utils/imageCompression"; export default function NoteDetailPage() { const { id } = useParams(); @@ -119,18 +120,28 @@ export default function NoteDetailPage() { const handleImageUpload = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files[0] || !note?.id) return; - const file = e.target.files[0]; - - // Vérifier la taille du fichier (5MB max) - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - showError("L'image est trop grande (max 5MB)"); - e.target.value = ''; - return; - } + const originalFile = e.target.files[0]; + const originalSize = formatFileSize(originalFile.size); try { - const image = await NotesService.uploadImage(note.id, file); + // Compresser l'image avant l'upload + const compressedFile = await compressImage(originalFile); + const compressedSize = formatFileSize(compressedFile.size); + + // Afficher un message si l'image a été compressée + if (compressedFile.size < originalFile.size) { + showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`); + } + + // Vérifier la taille finale (5MB max après compression) + const maxSize = 5 * 1024 * 1024; // 5MB + if (compressedFile.size > maxSize) { + showError("L'image est trop grande même après compression (max 5MB)"); + e.target.value = ''; + return; + } + + const image = await NotesService.uploadImage(note.id, compressedFile); if (image) { const updatedImages = [...(note.images || []), image]; setNote({ ...note, images: updatedImages }); @@ -139,7 +150,7 @@ export default function NoteDetailPage() { showError("Erreur lors de l'upload de l'image"); } } catch (error) { - showError("Erreur lors de l'upload"); + showError(error instanceof Error ? error.message : "Erreur lors de l'upload"); } e.target.value = ''; }; diff --git a/src/utils/imageCompression.ts b/src/utils/imageCompression.ts new file mode 100644 index 0000000..76e81fe --- /dev/null +++ b/src/utils/imageCompression.ts @@ -0,0 +1,154 @@ +/** + * Compresse une image en la redimensionnant et en réduisant sa qualité + * @param file - Le fichier image à compresser + * @param maxWidth - Largeur maximale (défaut: 1920px) + * @param maxHeight - Hauteur maximale (défaut: 1080px) + * @param quality - Qualité de compression JPEG (0-1, défaut: 0.8) + * @returns Promise - Le fichier image compressé + */ +export async function compressImage( + file: File, + maxWidth: number = 1920, + maxHeight: number = 1080, + quality: number = 0.8 +): Promise { + return new Promise((resolve, reject) => { + // Vérifier que c'est bien une image + if (!file.type.startsWith('image/')) { + reject(new Error('Le fichier n\'est pas une image')); + return; + } + + // Si l'image est déjà petite (< 1MB), on la retourne telle quelle + const oneMB = 1024 * 1024; + if (file.size < oneMB) { + resolve(file); + return; + } + + const reader = new FileReader(); + + reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier')); + + reader.onload = (e) => { + const img = new Image(); + + img.onerror = () => reject(new Error('Erreur lors du chargement de l\'image')); + + img.onload = () => { + try { + // Calculer les nouvelles dimensions en gardant le ratio + let width = img.width; + let height = img.height; + + if (width > maxWidth) { + height = (height * maxWidth) / width; + width = maxWidth; + } + + if (height > maxHeight) { + width = (width * maxHeight) / height; + height = maxHeight; + } + + // Créer un canvas pour redimensionner l'image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Impossible de créer le contexte canvas')); + return; + } + + // Dessiner l'image redimensionnée + ctx.drawImage(img, 0, 0, width, height); + + // Déterminer le type de sortie et la qualité + let outputType = 'image/jpeg'; + let outputQuality = quality; + + // Si c'est un PNG avec transparence, on garde le format PNG + if (file.type === 'image/png') { + // Vérifier si l'image a de la transparence + const imageData = ctx.getImageData(0, 0, width, height); + const hasTransparency = imageData.data.some((_, i) => i % 4 === 3 && imageData.data[i] < 255); + + if (hasTransparency) { + outputType = 'image/png'; + outputQuality = 0.9; // PNG utilise une compression sans perte + } + } + + // Convertir le canvas en Blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Erreur lors de la conversion de l\'image')); + return; + } + + // Si l'image compressée fait toujours plus de 2MB, on réduit encore la qualité + const twoMB = 2 * 1024 * 1024; + if (blob.size > twoMB && outputType === 'image/jpeg' && outputQuality > 0.5) { + // Réessayer avec une qualité plus faible + canvas.toBlob( + (blob2) => { + if (!blob2) { + reject(new Error('Erreur lors de la deuxième compression')); + return; + } + + // Créer un nouveau fichier avec le blob compressé + const compressedFile = new File( + [blob2], + file.name.replace(/\.[^/.]+$/, '.jpg'), // Remplacer l'extension par .jpg + { type: 'image/jpeg' } + ); + + resolve(compressedFile); + }, + 'image/jpeg', + 0.6 // Qualité réduite + ); + return; + } + + // Créer un nouveau fichier avec le blob compressé + const extension = outputType === 'image/png' ? '.png' : '.jpg'; + const compressedFile = new File( + [blob], + file.name.replace(/\.[^/.]+$/, extension), + { type: outputType } + ); + + resolve(compressedFile); + }, + outputType, + outputQuality + ); + } catch (error) { + reject(error); + } + }; + + img.src = e.target?.result as string; + }; + + reader.readAsDataURL(file); + }); +} + +/** + * Formate la taille d'un fichier en MB ou KB + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} From 21d3f7057fed7c5341a19e2c22a5fa5290c98cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:31:19 +0000 Subject: [PATCH 07/55] feat: add dark/light/system theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete theme switching with three modes: - Light theme - Dark theme - System (auto-detect from OS) Changes: - Created ModeToggle component with dropdown menu (src/components/mode-toggle.tsx) - Added theme toggle to desktop header (Index.tsx) - Enhanced mobile settings with radio group for theme selection (SettingsPage.tsx) - Changed default theme from "light" to "system" for better UX (App.tsx) Features: ✅ Seamless theme switching without page reload ✅ Persists user preference in localStorage ✅ System theme auto-detects OS preference ✅ Smooth transitions between themes ✅ Works on both mobile and desktop Desktop: Theme toggle button in top navigation Mobile: Theme settings in Settings page with Light/Dark/System options --- src/App.tsx | 2 +- src/components/mode-toggle.tsx | 36 +++++++++++++++++++++++++++++ src/pages/Index.tsx | 3 +++ src/pages/mobile/SettingsPage.tsx | 38 +++++++++++++++++++------------ 4 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 src/components/mode-toggle.tsx diff --git a/src/App.tsx b/src/App.tsx index 42ed1da..06ed6d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ const App = () => { return ( - + diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..f0a5d04 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -0,0 +1,36 @@ +import { Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme } from "@/components/theme-provider"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Clair + + setTheme("dark")}> + Sombre + + setTheme("system")}> + Système + + + + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b553b3b..14a4138 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -32,6 +32,7 @@ import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; +import { ModeToggle } from "@/components/mode-toggle"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -923,6 +924,8 @@ const Index = () => { Nouvelle Note + + {user && ( diff --git a/src/pages/mobile/SettingsPage.tsx b/src/pages/mobile/SettingsPage.tsx index 8f528ad..be2bcb7 100644 --- a/src/pages/mobile/SettingsPage.tsx +++ b/src/pages/mobile/SettingsPage.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { User, LogOut, @@ -53,10 +54,6 @@ export default function SettingsPage() { } }; - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark"); - }; - return (
{/* Appearance */} @@ -68,16 +65,29 @@ export default function SettingsPage() { -
- - -
+ setTheme(value as "light" | "dark" | "system")}> +
+ + +
+
+ + +
+
+ + +
+
From 94530246af925461361381341a9fd3eb2180c507 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:34:54 +0000 Subject: [PATCH 08/55] feat: add markdown export functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive markdown export for notes and todos with automatic HTML to Markdown conversion. New Features: - Export single note to Markdown file - Export all notes to combined Markdown - Export todos list to Markdown - Smart HTML to Markdown conversion - Preserves formatting, links, lists, and code blocks Files Created: - src/utils/markdownExport.ts - Complete export utilities Desktop (Index.tsx): - Added FileDown icon to imports - New export button in note editor toolbar - Downloads note as .md file with one click Mobile (NoteDetailPage.tsx): - Added "Exporter en Markdown" to dropdown menu - Same functionality as desktop version Markdown Export Features: ✅ Converts HTML formatting to Markdown syntax ✅ Includes note metadata (creation date, tags, priority) ✅ Exports todos as checkboxes [ ] or [x] ✅ Includes image references ✅ Automatic filename generation ✅ One-click download Export includes: - Note title as H1 - Creation date and metadata - Tags with # prefix - Priority indicator ⭐ - Full content with formatting preserved - Todo items with completion status - Image links Perfect for: - Backing up notes - Sharing notes externally - Version control (git) - Using notes in other markdown editors --- src/pages/Index.tsx | 16 +- src/pages/mobile/NoteDetailPage.tsx | 13 +- src/utils/markdownExport.ts | 241 ++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/utils/markdownExport.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 14a4138..325f07f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -33,6 +33,7 @@ import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; import { ModeToggle } from "@/components/mode-toggle"; +import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1278,6 +1279,19 @@ const Index = () => {
+ + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 325f07f..8103690 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -34,6 +34,8 @@ import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; import { ModeToggle } from "@/components/mode-toggle"; import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; +import { TemplateSelector } from "@/components/TemplateSelector"; +import type { NoteTemplate } from "@/utils/noteTemplates"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -147,6 +149,7 @@ const Index = () => { const [addUserModal, setAddUserModal] = useState(false); const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false }); + const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); const [addNoteTodoModal, setAddNoteTodoModal] = useState(false); const [addTagModal, setAddTagModal] = useState(false); const [selectedImage, setSelectedImage] = useState(null); @@ -410,12 +413,32 @@ const Index = () => { navigate("/login"); }; - const handleCreateNote = async () => { + const handleCreateNote = () => { + setTemplateSelectorOpen(true); + }; + + const handleCreateNoteFromTemplate = async (template: NoteTemplate) => { try { - const newNote = await NotesService.createNote("Nouvelle note"); + const title = template.name === "Note vide" ? "Nouvelle note" : template.name; + const newNote = await NotesService.createNote(title, template.content); if (newNote) { - setNotes([newNote, ...notes]); - setOpenNote(newNote); + // Add todos if template has any + if (template.todos && template.todos.length > 0) { + for (const todo of template.todos) { + await NotesService.addTodo(newNote.id!, todo.text); + } + // Reload note to get the todos + const notes = await NotesService.getNotes(); + const updatedNote = notes.find(n => n.id === newNote.id); + if (updatedNote) { + setNotes([updatedNote, ...notes.filter(n => n.id !== newNote.id)]); + setOpenNote(updatedNote); + } + } else { + setNotes([newNote, ...notes]); + setOpenNote(newNote); + } + showSuccess("Note créée depuis le template"); } } catch (error) { showError("Erreur lors de la création de la note"); @@ -2287,6 +2310,13 @@ const Index = () => { confirmText="Ajouter" /> + {/* Template Selector */} + + {/* Add Event Modal */} diff --git a/src/pages/mobile/NotesPage.tsx b/src/pages/mobile/NotesPage.tsx index d42f085..be1a682 100644 --- a/src/pages/mobile/NotesPage.tsx +++ b/src/pages/mobile/NotesPage.tsx @@ -8,6 +8,8 @@ import { MobileCard } from "@/components/mobile/MobileCard"; import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText } from "lucide-react"; import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; +import { TemplateSelector } from "@/components/TemplateSelector"; +import type { NoteTemplate } from "@/utils/noteTemplates"; const NOTES_PER_PAGE = 10; @@ -16,6 +18,7 @@ export default function NotesPage() { const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [page, setPage] = useState(0); + const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); const navigate = useNavigate(); const loadNotes = async () => { @@ -48,10 +51,24 @@ export default function NotesPage() { const totalPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); const paginatedNotes = filteredNotes.slice(page * NOTES_PER_PAGE, (page + 1) * NOTES_PER_PAGE); - const handleCreateNote = async () => { + const handleCreateNote = () => { + setTemplateSelectorOpen(true); + }; + + const handleCreateNoteFromTemplate = async (template: NoteTemplate) => { try { - const newNote = await NotesService.createNote("Nouvelle note", ""); - navigate(`/mobile/notes/${newNote.id}`); + const title = template.name === "Note vide" ? "Nouvelle note" : template.name; + const newNote = await NotesService.createNote(title, template.content); + if (newNote) { + // Add todos if template has any + if (template.todos && template.todos.length > 0) { + for (const todo of template.todos) { + await NotesService.addTodo(newNote.id!, todo.text); + } + } + showSuccess("Note créée depuis le template"); + navigate(`/mobile/notes/${newNote.id}`); + } } catch (error) { showError("Erreur lors de la création de la note"); } @@ -238,6 +255,13 @@ export default function NotesPage() { {/* FAB */} + + {/* Template Selector */} +
); } diff --git a/src/utils/noteTemplates.ts b/src/utils/noteTemplates.ts new file mode 100644 index 0000000..c31ea22 --- /dev/null +++ b/src/utils/noteTemplates.ts @@ -0,0 +1,363 @@ +export interface NoteTemplate { + id: string; + name: string; + description: string; + icon: string; + category: string; + content: string; + todos?: Array<{ text: string; completed: boolean }>; +} + +export const DEFAULT_TEMPLATES: NoteTemplate[] = [ + { + id: 'blank', + name: 'Note vide', + description: 'Commencer avec une page blanche', + icon: '📄', + category: 'Général', + content: '', + todos: [] + }, + { + id: 'meeting', + name: 'Réunion', + description: 'Notes de réunion structurées', + icon: '🤝', + category: 'Travail', + content: `

Réunion - [Titre]

+

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Participants:

+

Objectif:

+ +

Points discutés

+

1.

+

2.

+

3.

+ +

Décisions

+

•

+ +

Prochaines étapes

+

→

`, + todos: [ + { text: 'Envoyer le compte-rendu', completed: false }, + { text: 'Planifier le suivi', completed: false } + ] + }, + { + id: 'daily-journal', + name: 'Journal quotidien', + description: 'Réflexions et gratitude du jour', + icon: '📔', + category: 'Personnel', + content: `

Journal - ${new Date().toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}

+ +

🌅 Ce matin je me sens...

+

+ +

🎯 Mes priorités aujourd'hui

+

1.

+

2.

+

3.

+ +

💡 Idées et réflexions

+

+ +

🙏 Gratitude

+

Aujourd'hui je suis reconnaissant(e) pour:

+

•

+ +

🌙 Bilan de la journée

+

`, + todos: [] + }, + { + id: 'project-planning', + name: 'Planification de projet', + description: 'Organiser un nouveau projet', + icon: '🎯', + category: 'Travail', + content: `

Projet - [Nom du projet]

+ +

📋 Vue d'ensemble

+

Objectif:

+

Deadline:

+

Responsable:

+ +

🎯 Objectifs

+

1.

+

2.

+

3.

+ +

📊 Phases du projet

+

Phase 1: Préparation

+

•

+ +

Phase 2: Développement

+

•

+ +

Phase 3: Finalisation

+

•

+ +

👥 Équipe

+

•

+ +

⚠️ Risques identifiés

+

•

+ +

📌 Ressources nécessaires

+

•

`, + todos: [ + { text: 'Définir le scope', completed: false }, + { text: 'Assembler l\'équipe', completed: false }, + { text: 'Créer le planning', completed: false } + ] + }, + { + id: 'brainstorming', + name: 'Brainstorming', + description: 'Session de génération d\'idées', + icon: '💭', + category: 'Créativité', + content: `

💭 Brainstorming - [Sujet]

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Question centrale:

+ +

💡 Idées

+

1.

+

2.

+

3.

+

4.

+

5.

+ +

⭐ Idées à creuser

+

•

+ +

🚀 Actions possibles

+

→

+ +

🔗 Connexions et insights

+

`, + todos: [] + }, + { + id: 'recipe', + name: 'Recette de cuisine', + description: 'Noter une recette', + icon: '👨‍🍳', + category: 'Personnel', + content: `

🍳 Recette - [Nom du plat]

+ +

⏱️ Temps de préparation:

+

👥 Portions:

+

😋 Difficulté: Facile / Moyen / Difficile

+ +

📝 Ingrédients

+

•

+ +

👨‍🍳 Préparation

+

1.

+

2.

+

3.

+ +

💡 Astuces

+

•

+ +

📌 Notes

+

`, + todos: [] + }, + { + id: 'weekly-review', + name: 'Revue hebdomadaire', + description: 'Bilan et planification de la semaine', + icon: '📊', + category: 'Productivité', + content: `

📊 Revue de la semaine

+

Semaine du: ${new Date().toLocaleDateString('fr-FR')}

+ +

✅ Accomplissements

+

Cette semaine j'ai réussi à:

+

1.

+

2.

+

3.

+ +

📈 Progrès

+

•

+ +

⚠️ Défis rencontrés

+

•

+ +

💡 Leçons apprises

+

•

+ +

🎯 Objectifs semaine prochaine

+

1.

+

2.

+

3.

+ +

🔄 Habitudes à améliorer

+

•

`, + todos: [ + { text: 'Planifier la semaine prochaine', completed: false } + ] + }, + { + id: 'bug-report', + name: 'Rapport de bug', + description: 'Documenter un problème technique', + icon: '🐛', + category: 'Développement', + content: `

🐛 Bug Report

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Priorité: 🔴 Critique / 🟠 Haute / 🟡 Moyenne / 🟢 Basse

+

Statut: Nouveau / En cours / Résolu

+ +

📋 Description

+

+ +

🔄 Étapes pour reproduire

+

1.

+

2.

+

3.

+ +

✅ Résultat attendu

+

+ +

❌ Résultat actuel

+

+ +

💻 Environnement

+

OS:

+

Navigateur:

+

Version:

+ +

📌 Notes additionnelles

+

+ +

🔧 Solution proposée

+

`, + todos: [ + { text: 'Reproduire le bug', completed: false }, + { text: 'Identifier la cause', completed: false }, + { text: 'Implémenter le fix', completed: false }, + { text: 'Tester la solution', completed: false } + ] + }, + { + id: 'learning-notes', + name: 'Notes d\'apprentissage', + description: 'Apprendre un nouveau sujet', + icon: '📚', + category: 'Éducation', + content: `

📚 Notes d'apprentissage - [Sujet]

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Source:

+ +

🎯 Objectifs d'apprentissage

+

•

+ +

📝 Concepts clés

+

Concept 1:

+

•

+ +

Concept 2:

+

•

+ +

💡 Exemples

+

+ +

❓ Questions

+

•

+ +

🔗 Ressources

+

•

+ +

📌 À retenir

+

•

+ +

✍️ Pratique

+

Exercices à faire:

+

1.

`, + todos: [ + { text: 'Réviser les notes', completed: false }, + { text: 'Faire les exercices', completed: false } + ] + }, + { + id: 'travel-planning', + name: 'Planification de voyage', + description: 'Organiser un déplacement', + icon: '✈️', + category: 'Personnel', + content: `

✈️ Voyage - [Destination]

+ +

📅 Dates: du [date] au [date]

+

👥 Voyageurs:

+

💰 Budget:

+ +

🎒 À emporter

+

Vêtements:

+

•

+ +

Documents:

+

• Passeport

+

• Billets

+

• Assurance

+ +

🏨 Hébergement

+

Hôtel/Airbnb:

+

Adresse:

+

Check-in:

+ +

✈️ Transport

+

Vol aller:

+

Vol retour:

+ +

📍 Lieux à visiter

+

1.

+

2.

+

3.

+ +

🍽️ Restaurants recommandés

+

•

+ +

💡 Conseils et infos pratiques

+

•

`, + todos: [ + { text: 'Réserver les vols', completed: false }, + { text: 'Réserver l\'hébergement', completed: false }, + { text: 'Faire la valise', completed: false } + ] + } +]; + +export function getTemplatesByCategory(): Map { + const categorized = new Map(); + + DEFAULT_TEMPLATES.forEach(template => { + const category = template.category; + if (!categorized.has(category)) { + categorized.set(category, []); + } + categorized.get(category)!.push(template); + }); + + return categorized; +} + +export function getTemplateById(id: string): NoteTemplate | undefined { + return DEFAULT_TEMPLATES.find(t => t.id === id); +} + +export function searchTemplates(query: string): NoteTemplate[] { + const lowerQuery = query.toLowerCase(); + return DEFAULT_TEMPLATES.filter(template => + template.name.toLowerCase().includes(lowerQuery) || + template.description.toLowerCase().includes(lowerQuery) || + template.category.toLowerCase().includes(lowerQuery) + ); +} From a1addbacba44b40dde042e0886f04580b43503cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:48:02 +0000 Subject: [PATCH 11/55] feat: add advanced search with filters for notes - Create AdvancedSearch component with multiple filter options - Add filters for tags, date range, content type (todos/images/files), and priority - Integrate advanced search in desktop and mobile note pages - Extract unique tags from notes for filter dropdown - Support multiple simultaneous filters with clear visual feedback - Reset pagination when filters change --- src/components/AdvancedSearch.tsx | 283 ++++++++++++++++++++++++++++++ src/pages/Index.tsx | 106 +++++++++-- src/pages/mobile/NotesPage.tsx | 88 +++++++++- 3 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 src/components/AdvancedSearch.tsx diff --git a/src/components/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx new file mode 100644 index 0000000..34b365a --- /dev/null +++ b/src/components/AdvancedSearch.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Calendar } from "@/components/ui/calendar"; +import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; + +export interface SearchFilters { + tags: string[]; // Tag names + dateFrom?: Date; + dateTo?: Date; + hasTodos?: boolean; + hasImages?: boolean; + hasFiles?: boolean; + priority?: boolean; +} + +export interface TagOption { + id: number; + name: string; +} + +interface AdvancedSearchProps { + filters: SearchFilters; + onFiltersChange: (filters: SearchFilters) => void; + availableTags: TagOption[]; +} + +export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) { + const [open, setOpen] = useState(false); + const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null); + const [tagSelectorOpen, setTagSelectorOpen] = useState(false); + + const hasActiveFilters = filters.tags.length > 0 || + filters.dateFrom !== undefined || + filters.dateTo !== undefined || + filters.hasTodos !== undefined || + filters.hasImages !== undefined || + filters.hasFiles !== undefined || + filters.priority !== undefined; + + const clearFilters = () => { + onFiltersChange({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); + }; + + const toggleTag = (tagName: string) => { + const newTags = filters.tags.includes(tagName) + ? filters.tags.filter(name => name !== tagName) + : [...filters.tags, tagName]; + onFiltersChange({ ...filters, tags: newTags }); + }; + + const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name)); + + return ( +
+ + + + + +
+
+

Filtres avancés

+ {hasActiveFilters && ( + + )} +
+ + {/* Tags Filter */} +
+ + + + + + + + + + Aucun tag trouvé + + {availableTags.map((tag) => ( + toggleTag(tag.name)} + className="cursor-pointer" + > + + + {tag.name} + + ))} + + + + + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag.name} + toggleTag(tag.name)} + /> + + ))} +
+ )} +
+ + {/* Date Range Filter */} +
+ +
+ setDatePickerOpen(open ? 'from' : null)}> + + + + + { + onFiltersChange({ ...filters, dateFrom: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + + setDatePickerOpen(open ? 'to' : null)}> + + + + + { + onFiltersChange({ ...filters, dateTo: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + +
+ {(filters.dateFrom || filters.dateTo) && ( + + )} +
+ + {/* Content Type Filters */} +
+ +
+
+ + onFiltersChange({ ...filters, hasTodos: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasImages: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasFiles: checked ? true : undefined }) + } + /> + +
+
+
+ + {/* Priority Filter */} +
+
+ + onFiltersChange({ ...filters, priority: checked ? true : undefined }) + } + /> + +
+
+
+
+
+ + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 8103690..33f4907 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -36,6 +36,7 @@ import { ModeToggle } from "@/components/mode-toggle"; import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; +import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -115,6 +116,15 @@ const Index = () => { const [user, setUser] = useState(null); const [showAdmin, setShowAdmin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [searchFilters, setSearchFilters] = useState({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); const [notes, setNotes] = useState([]); const [openNote, setOpenNote] = useState(null); const [users, setUsers] = useState([]); @@ -828,12 +838,71 @@ const Index = () => { } }; - 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()))) - ); + // Extract unique tags from all notes + const availableTags: TagOption[] = Array.from( + new Map( + notes.flatMap(note => + (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }]) + ) + ).values() + ).sort((a, b) => a.name.localeCompare(b.name)); + + const filteredNotes = notes.filter(note => { + // Archived filter + if (showArchived ? !note.archived : note.archived) return false; + + // Text search + if (searchQuery !== "" && + !(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) && + !(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) { + return false; + } + + // Tags filter + if (searchFilters.tags.length > 0) { + const noteTags = (note.tags || []).map(t => t.name); + if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) { + return false; + } + } + + // Date range filter + if (searchFilters.dateFrom || searchFilters.dateTo) { + const noteDate = new Date(note.updated_at || note.created_at || 0); + if (searchFilters.dateFrom) { + const fromDate = new Date(searchFilters.dateFrom); + fromDate.setHours(0, 0, 0, 0); + if (noteDate < fromDate) return false; + } + if (searchFilters.dateTo) { + const toDate = new Date(searchFilters.dateTo); + toDate.setHours(23, 59, 59, 999); + if (noteDate > toDate) return false; + } + } + + // Has todos filter + if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) { + return false; + } + + // Has images filter + if (searchFilters.hasImages && (!note.images || note.images.length === 0)) { + return false; + } + + // Has files filter + if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) { + return false; + } + + // Priority filter + if (searchFilters.priority && !note.priority) { + return false; + } + + return true; + }); // Pagination for notes const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); @@ -845,7 +914,7 @@ const Index = () => { // Reset page when changing filters or search useEffect(() => { setNotesPage(0); - }, [showArchived, searchQuery]); + }, [showArchived, searchQuery, searchFilters]); useEffect(() => { setTodosActivePage(0); @@ -1143,14 +1212,21 @@ const Index = () => {
-
- - setSearchQuery(e.target.value)} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
diff --git a/src/pages/mobile/NotesPage.tsx b/src/pages/mobile/NotesPage.tsx index be1a682..83158a1 100644 --- a/src/pages/mobile/NotesPage.tsx +++ b/src/pages/mobile/NotesPage.tsx @@ -10,12 +10,22 @@ import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; +import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; const NOTES_PER_PAGE = 10; export default function NotesPage() { const [notes, setNotes] = useState([]); const [searchQuery, setSearchQuery] = useState(""); + const [searchFilters, setSearchFilters] = useState({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); const [showArchived, setShowArchived] = useState(false); const [page, setPage] = useState(0); const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); @@ -34,13 +44,71 @@ export default function NotesPage() { loadNotes(); }, []); + // Extract unique tags from all notes + const availableTags: TagOption[] = Array.from( + new Map( + notes.flatMap(note => + (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }]) + ) + ).values() + ).sort((a, b) => a.name.localeCompare(b.name)); + const filteredNotes = notes .filter(note => { - const matchArchived = showArchived ? note.archived : !note.archived; - const matchSearch = searchQuery === "" || - note.title?.toLowerCase().includes(searchQuery.toLowerCase()) || - note.content?.toLowerCase().includes(searchQuery.toLowerCase()); - return matchArchived && matchSearch; + // Archived filter + if (showArchived ? !note.archived : note.archived) return false; + + // Text search + if (searchQuery !== "" && + !(note.title?.toLowerCase().includes(searchQuery.toLowerCase())) && + !(note.content?.toLowerCase().includes(searchQuery.toLowerCase()))) { + return false; + } + + // Tags filter + if (searchFilters.tags.length > 0) { + const noteTags = (note.tags || []).map(t => t.name); + if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) { + return false; + } + } + + // Date range filter + if (searchFilters.dateFrom || searchFilters.dateTo) { + const noteDate = new Date(note.updated_at || note.created_at || 0); + if (searchFilters.dateFrom) { + const fromDate = new Date(searchFilters.dateFrom); + fromDate.setHours(0, 0, 0, 0); + if (noteDate < fromDate) return false; + } + if (searchFilters.dateTo) { + const toDate = new Date(searchFilters.dateTo); + toDate.setHours(23, 59, 59, 999); + if (noteDate > toDate) return false; + } + } + + // Has todos filter + if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) { + return false; + } + + // Has images filter + if (searchFilters.hasImages && (!note.images || note.images.length === 0)) { + return false; + } + + // Has files filter + if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) { + return false; + } + + // Priority filter + if (searchFilters.priority && !note.priority) { + return false; + } + + return true; }) .sort((a, b) => { if (a.priority && !b.priority) return -1; @@ -106,6 +174,16 @@ export default function NotesPage() { /> + {/* Advanced Filters */} + { + setSearchFilters(newFilters); + setPage(0); + }} + availableTags={availableTags} + /> + {/* Filter Tabs */}
+ + +
+ + {/* Timer Display */} +
+
+ {formatTime(timeLeft)} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + + )} + +
+ + {/* Session Info */} +
+ {mode === 'work' && ( +

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

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

Pause courte - Profitez-en pour vous étirer

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

Pause longue - Temps de vraiment déconnecter

} +
+ + + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 33f4907..ba5de9f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -37,6 +37,7 @@ import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMark import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; +import { PomodoroTimer } from "@/components/PomodoroTimer"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1740,8 +1741,13 @@ const Index = () => { )}
- {/* Right Column: Todos & RSS Boxes side by side */} -
+ {/* Right Column: Pomodoro Timer, Todos & RSS */} +
+ {/* Pomodoro Timer */} + + + {/* Todos & RSS Boxes side by side */} +
{/* Todos Box */} @@ -1971,6 +1977,7 @@ const Index = () => {
+
From 712d3fd21a598ad611eaaad4b87ec3335ffa8f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:01:14 +0000 Subject: [PATCH 14/55] feat: add Kanban board view for notes organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create KanbanBoard component with drag-and-drop functionality - Support 5 columns: Backlog, À faire, En cours, En révision, Terminé - Use tag-based system to track note status in columns - Add view toggle between List and Kanban modes in desktop interface - Display note metadata (todos, images, files, tags) in Kanban cards - Support moving notes between columns by dragging - Auto-update tags when moving notes to different columns - Show task completion progress in Kanban cards --- src/components/KanbanBoard.tsx | 196 +++++++++++++++++++++++++++++++++ src/pages/Index.tsx | 67 ++++++++++- 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/components/KanbanBoard.tsx diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..4e5ac7b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react"; +import { Note } from "@/services/NotesService"; + +interface KanbanColumn { + id: string; + title: string; + color: string; +} + +const DEFAULT_COLUMNS: KanbanColumn[] = [ + { id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' }, + { id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' }, + { id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' }, + { id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' }, + { id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' }, +]; + +interface KanbanBoardProps { + notes: Note[]; + onNoteClick: (note: Note) => void; + onCreateNote: (columnId: string) => void; + onMoveNote?: (noteId: number, newStatus: string) => void; +} + +export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) { + const [draggedNote, setDraggedNote] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + + // Group notes by their kanban_status tag or default to 'backlog' + const getNoteStatus = (note: Note): string => { + const statusTag = note.tags?.find(tag => + DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase()) + ); + return statusTag?.name.toLowerCase() || 'backlog'; + }; + + const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => { + acc[column.id] = notes.filter(note => + !note.archived && getNoteStatus(note) === column.id + ); + return acc; + }, {} as Record); + + const handleDragStart = (note: Note) => { + setDraggedNote(note); + }; + + const handleDragOver = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(columnId); + }; + + const handleDragLeave = () => { + setDragOverColumn(null); + }; + + const handleDrop = (e: React.DragEvent, columnId: string) => { + e.preventDefault(); + setDragOverColumn(null); + + if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) { + onMoveNote(draggedNote.id!, columnId); + } + setDraggedNote(null); + }; + + const handleDragEnd = () => { + setDraggedNote(null); + setDragOverColumn(null); + }; + + return ( +
+ {DEFAULT_COLUMNS.map((column) => ( +
handleDragOver(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > + + +
+ +
+ {column.title} + + {notesByColumn[column.id]?.length || 0} + + + +
+ + + +
+ {notesByColumn[column.id]?.map((note) => ( +
handleDragStart(note)} + onDragEnd={handleDragEnd} + onClick={() => onNoteClick(note)} + className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${ + draggedNote?.id === note.id ? 'opacity-50' : '' + } ${note.priority ? 'border-l-4 border-l-red-500' : ''}`} + > +
+ +
+

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

+ {note.content && ( +

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

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

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

+ )} +
+ ))} + {notesByColumn[column.id]?.length === 0 && ( +
+ Aucune note +
+ )} +
+
+
+ +
+ ))} +
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ba5de9f..44bc16a 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -38,6 +38,7 @@ import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; import { PomodoroTimer } from "@/components/PomodoroTimer"; +import { KanbanBoard } from "@/components/KanbanBoard"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -136,6 +137,7 @@ const Index = () => { const [settings, setSettings] = useState({}); const [adminTab, setAdminTab] = useState("users"); const [showArchived, setShowArchived] = useState(false); + const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list'); const [noteTags, setNoteTags] = useState([]); const [openRouterModels, setOpenRouterModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); @@ -1229,11 +1231,35 @@ const Index = () => { onFiltersChange={setSearchFilters} availableTags={availableTags} /> + + {/* View Mode Toggle */} +
+ + +
- {/* Notes Grid - 1 column for better visibility */} -
+ {/* Notes View - List or Kanban */} + {notesViewMode === 'list' ? ( + <> +
{filteredNotes.length > 0 ? ( paginatedNotes.map(note => ( {
)} + + ) : ( + /* Kanban View */ + { + setTemplateSelectorOpen(true); + // TODO: Set initial tag based on columnId + }} + onMoveNote={async (noteId, newStatus) => { + // Move note to new column by updating its status tag + const note = notes.find(n => n.id === noteId); + if (!note) return; + + // Remove old status tags + const oldStatusTags = note.tags?.filter(tag => + ['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase()) + ) || []; + + for (const tag of oldStatusTags) { + if (tag.id && note.id) { + await TagsService.deleteTag(note.id, tag.id); + } + } + + // Add new status tag + if (note.id) { + await TagsService.addTag(note.id, newStatus); + await loadNotes(); // Refresh notes + showSuccess(`Note déplacée vers ${newStatus}`); + } + }} + /> + )} ) : ( /* Open Note Editor */ From e48bc1eaf1c5aeadd2ba9d13ed4e4257bbe6a942 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:03:41 +0000 Subject: [PATCH 15/55] feat: add smart tag auto-suggestions for notes - Create smartTags utility with keyword extraction algorithm - Filter out 100+ French stop words for better keyword quality - Implement category-based tag suggestions (travail, dev, design, etc.) - Suggest tags from similar notes based on content similarity - Display smart suggestions with one-click add in note editor - Show sparkle icon to indicate AI-assisted suggestions - Combine multiple suggestion strategies for best results - Limit to top 8 most relevant suggestions per note --- src/pages/Index.tsx | 31 ++++++- src/utils/smartTags.ts | 179 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 src/utils/smartTags.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 44bc16a..6ed7891 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List, Sparkles } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -39,6 +39,7 @@ import type { NoteTemplate } from "@/utils/noteTemplates"; import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; import { PomodoroTimer } from "@/components/PomodoroTimer"; import { KanbanBoard } from "@/components/KanbanBoard"; +import { getSmartTagSuggestions } from "@/utils/smartTags"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1603,6 +1604,34 @@ const Index = () => {
)} + {/* Smart Tag Suggestions */} + {openNote && (() => { + const existingTags = noteTags.map(t => t.tag); + const suggestions = getSmartTagSuggestions(openNote, notes, existingTags); + + return suggestions.length > 0 && ( +
+

+ + Suggestions de tags +

+
+ {suggestions.map((suggestion) => ( + confirmAddTag(suggestion)} + > + + {suggestion} + + ))} +
+
+ ); + })()} + {/* Todos */} {openNote.todos && openNote.todos.length > 0 && ( <> diff --git a/src/utils/smartTags.ts b/src/utils/smartTags.ts new file mode 100644 index 0000000..be45a24 --- /dev/null +++ b/src/utils/smartTags.ts @@ -0,0 +1,179 @@ +import { Note } from "@/services/NotesService"; + +// Common French stop words to filter out +const FRENCH_STOP_WORDS = new Set([ + 'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'et', 'ou', 'mais', 'donc', + 'or', 'ni', 'car', 'est', 'sont', 'être', 'avoir', 'a', 'dans', 'sur', 'pour', + 'par', 'avec', 'sans', 'sous', 'vers', 'chez', 'depuis', 'pendant', 'avant', + 'après', 'entre', 'parmi', 'selon', 'dont', 'que', 'qui', 'quoi', 'où', 'quand', + 'comment', 'pourquoi', 'ce', 'cet', 'cette', 'ces', 'mon', 'ton', 'son', 'ma', + 'ta', 'sa', 'mes', 'tes', 'ses', 'notre', 'votre', 'leur', 'nos', 'vos', 'leurs', + 'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on', 'me', 'te', 'se', + 'lui', 'leur', 'y', 'en', 'ne', 'pas', 'plus', 'moins', 'très', 'trop', 'assez', + 'beaucoup', 'peu', 'bien', 'mal', 'tout', 'toute', 'tous', 'toutes', 'même', + 'autre', 'autres', 'tel', 'telle', 'quel', 'quelle', 'quelque', 'plusieurs', + 'chaque', 'aucun', 'aucune', 'nul', 'nulle', 'certain', 'certaine' +]); + +// Category keywords for auto-tagging +const CATEGORY_KEYWORDS: Record = { + 'travail': ['réunion', 'projet', 'équipe', 'client', 'présentation', 'deadline', 'objectif', 'tâche', 'mission', 'entreprise', 'bureau', 'collègue', 'manager'], + 'personnel': ['maison', 'famille', 'ami', 'vacances', 'loisir', 'hobby', 'détente', 'week-end', 'anniversaire', 'fête'], + 'développement': ['code', 'bug', 'feature', 'api', 'database', 'frontend', 'backend', 'git', 'deploy', 'test', 'debug', 'javascript', 'python', 'react', 'node'], + 'design': ['ui', 'ux', 'interface', 'maquette', 'prototype', 'couleur', 'police', 'layout', 'wireframe', 'mockup', 'figma', 'sketch'], + 'finance': ['budget', 'dépense', 'économie', 'investissement', 'facture', 'paiement', 'compte', 'banque', 'argent', 'revenu'], + 'santé': ['sport', 'exercice', 'nutrition', 'médecin', 'santé', 'bien-être', 'fitness', 'régime', 'yoga', 'course'], + 'formation': ['cours', 'apprentissage', 'étude', 'formation', 'certifica', 'examen', 'leçon', 'tutoriel', 'documentation', 'lecture'], + 'idée': ['brainstorming', 'innovation', 'créativité', 'concept', 'inspiration', 'réflexion', 'hypothèse', 'proposition'], + 'urgent': ['urgent', 'important', 'prioritaire', 'asap', 'deadline', 'critique', 'essentiel'], + 'meeting': ['réunion', 'rendez-vous', 'appel', 'visio', 'conférence', 'présentation', 'démonstration'] +}; + +/** + * Extract keywords from text content + */ +export function extractKeywords(text: string, limit = 10): string[] { + if (!text) return []; + + // Remove HTML tags + const cleanText = text.replace(/<[^>]*>/g, ' '); + + // Convert to lowercase and split into words + const words = cleanText + .toLowerCase() + .replace(/[^\w\sàâäæçéèêëïîôùûüÿœ]/g, ' ') + .split(/\s+/) + .filter(word => word.length > 3) // Min 4 chars + .filter(word => !FRENCH_STOP_WORDS.has(word)); + + // Count word frequency + const frequency = new Map(); + words.forEach(word => { + frequency.set(word, (frequency.get(word) || 0) + 1); + }); + + // Sort by frequency and take top N + return Array.from(frequency.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([word]) => word); +} + +/** + * Suggest category tags based on content + */ +export function suggestCategoryTags(text: string): string[] { + if (!text) return []; + + const cleanText = text.toLowerCase().replace(/<[^>]*>/g, ' '); + const suggestions: string[] = []; + + for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) { + // Check if any category keyword appears in the text + const matches = keywords.filter(keyword => + cleanText.includes(keyword.toLowerCase()) + ); + + // If multiple matches, suggest this category + if (matches.length >= 2) { + suggestions.push(category); + } + } + + return suggestions; +} + +/** + * Suggest tags based on existing tags from similar notes + */ +export function suggestFromSimilarNotes( + currentNote: Note, + allNotes: Note[], + limit = 5 +): string[] { + if (!currentNote.title && !currentNote.content) return []; + + const currentKeywords = extractKeywords( + `${currentNote.title || ''} ${currentNote.content || ''}`, + 15 + ); + + if (currentKeywords.length === 0) return []; + + // Find notes with similar keywords + const noteSimilarity = allNotes + .filter(note => note.id !== currentNote.id && note.tags && note.tags.length > 0) + .map(note => { + const noteKeywords = extractKeywords( + `${note.title || ''} ${note.content || ''}`, + 15 + ); + + // Calculate similarity (common keywords / total unique keywords) + const commonKeywords = currentKeywords.filter(kw => + noteKeywords.includes(kw) + ).length; + + const totalKeywords = new Set([...currentKeywords, ...noteKeywords]).size; + const similarity = totalKeywords > 0 ? commonKeywords / totalKeywords : 0; + + return { + note, + similarity + }; + }) + .filter(item => item.similarity > 0.2) // Min 20% similarity + .sort((a, b) => b.similarity - a.similarity) + .slice(0, 5); // Top 5 similar notes + + // Extract tags from similar notes + const tagFrequency = new Map(); + noteSimilarity.forEach(({ note }) => { + note.tags?.forEach(tag => { + const tagName = tag.name.toLowerCase(); + tagFrequency.set(tagName, (tagFrequency.get(tagName) || 0) + 1); + }); + }); + + // Sort by frequency and return top tags + return Array.from(tagFrequency.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([tag]) => tag); +} + +/** + * Get all tag suggestions for a note + */ +export function getSmartTagSuggestions( + note: Note, + allNotes: Note[], + existingTags: string[] = [] +): string[] { + const content = `${note.title || ''} ${note.content || ''}`; + + // Get category suggestions + const categoryTags = suggestCategoryTags(content); + + // Get suggestions from similar notes + const similarNoteTags = suggestFromSimilarNotes(note, allNotes); + + // Get keyword-based suggestions + const keywords = extractKeywords(content, 5); + + // Combine all suggestions + const allSuggestions = [ + ...categoryTags, + ...similarNoteTags, + ...keywords + ]; + + // Filter out existing tags and duplicates + const existingTagsLower = existingTags.map(t => t.toLowerCase()); + const uniqueSuggestions = Array.from( + new Set(allSuggestions.map(t => t.toLowerCase())) + ).filter(tag => !existingTagsLower.includes(tag)); + + // Return top 8 suggestions + return uniqueSuggestions.slice(0, 8); +} From ea926cb1a13874e44b4868a6a892b46b08422cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:04:33 +0000 Subject: [PATCH 16/55] feat: add note linking system with wiki-style syntax - Create noteLinks utility for wiki-style [[Note Title]] links - Support automatic link detection and conversion to clickable HTML - Show broken links (when target note doesn't exist) in gray strikethrough - Implement backlinks detection (find notes linking to current note) - Add autocomplete suggestions when typing [[ for quick note selection - Support cursor position detection for inline link editing - Prioritize exact matches and recent notes in suggestions - Convert links to HTML with custom styling and click handlers --- src/utils/noteLinks.ts | 187 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/utils/noteLinks.ts diff --git a/src/utils/noteLinks.ts b/src/utils/noteLinks.ts new file mode 100644 index 0000000..a556c2d --- /dev/null +++ b/src/utils/noteLinks.ts @@ -0,0 +1,187 @@ +import { Note } from "@/services/NotesService"; + +/** + * Pattern to match wiki-style links: [[Note Title]] + */ +const WIKI_LINK_PATTERN = /\[\[([^\]]+)\]\]/g; + +/** + * Extract all note links from content + */ +export function extractNoteLinks(content: string): string[] { + if (!content) return []; + + const matches = content.matchAll(WIKI_LINK_PATTERN); + const links: string[] = []; + + for (const match of matches) { + if (match[1]) { + links.push(match[1].trim()); + } + } + + return [...new Set(links)]; // Remove duplicates +} + +/** + * Find notes that match the given link text + */ +export function findLinkedNotes(linkText: string, allNotes: Note[]): Note[] { + const searchText = linkText.toLowerCase().trim(); + + return allNotes.filter(note => { + if (!note.title) return false; + return note.title.toLowerCase().includes(searchText); + }); +} + +/** + * Convert wiki-style links to HTML clickable links + */ +export function convertLinksToHTML( + content: string, + allNotes: Note[], + onLinkClick: (noteId: number) => void +): string { + if (!content) return ''; + + return content.replace(WIKI_LINK_PATTERN, (match, linkText) => { + const trimmedText = linkText.trim(); + const matchedNotes = findLinkedNotes(trimmedText, allNotes); + + if (matchedNotes.length > 0) { + // Found matching note - create clickable link + const note = matchedNotes[0]; + return `${trimmedText}`; + } else { + // No matching note - show as grayed out + return `[[${trimmedText}]]`; + } + }); +} + +/** + * Get backlinks for a note (notes that link to this one) + */ +export function getBacklinks(note: Note, allNotes: Note[]): Note[] { + if (!note.title) return []; + + const backlinks: Note[] = []; + const noteTitle = note.title.toLowerCase(); + + for (const otherNote of allNotes) { + if (otherNote.id === note.id || !otherNote.content) continue; + + const links = extractNoteLinks(otherNote.content); + const hasLink = links.some(link => + link.toLowerCase().includes(noteTitle) || + noteTitle.includes(link.toLowerCase()) + ); + + if (hasLink) { + backlinks.push(otherNote); + } + } + + return backlinks; +} + +/** + * Suggest note titles for autocomplete when typing [[ + */ +export function suggestNoteTitles( + partialText: string, + allNotes: Note[], + limit = 10 +): Note[] { + if (!partialText) { + // Return recent notes when no search text + return allNotes + .filter(note => note.title && !note.archived) + .sort((a, b) => + new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime() + ) + .slice(0, limit); + } + + const searchText = partialText.toLowerCase().trim(); + + // Find notes matching the partial text + const matches = allNotes + .filter(note => { + if (!note.title || note.archived) return false; + return note.title.toLowerCase().includes(searchText); + }) + .sort((a, b) => { + // Prioritize exact matches and more recent notes + const aTitle = a.title?.toLowerCase() || ''; + const bTitle = b.title?.toLowerCase() || ''; + + const aExact = aTitle === searchText ? 1 : 0; + const bExact = bTitle === searchText ? 1 : 0; + + if (aExact !== bExact) return bExact - aExact; + + const aStarts = aTitle.startsWith(searchText) ? 1 : 0; + const bStarts = bTitle.startsWith(searchText) ? 1 : 0; + + if (aStarts !== bStarts) return bStarts - aStarts; + + // Fall back to recency + return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(); + }); + + return matches.slice(0, limit); +} + +/** + * Parse content and detect cursor position for link autocomplete + * Returns the partial link text if cursor is inside a [[...]] pattern + */ +export function detectLinkAtCursor(content: string, cursorPosition: number): { + isInLink: boolean; + linkText: string; + startPos: number; + endPos: number; +} | null { + // Find all [[ positions before cursor + const beforeCursor = content.substring(0, cursorPosition); + const lastOpenBracket = beforeCursor.lastIndexOf('[['); + + if (lastOpenBracket === -1) { + return null; // Not in a link + } + + // Check if there's a closing ]] between lastOpenBracket and cursor + const betweenBrackets = content.substring(lastOpenBracket, cursorPosition); + if (betweenBrackets.includes(']]')) { + return null; // Link is already closed + } + + // Find the closing ]] after cursor (if any) + const afterCursor = content.substring(cursorPosition); + const nextCloseBracket = afterCursor.indexOf(']]'); + const endPos = nextCloseBracket !== -1 + ? cursorPosition + nextCloseBracket + 2 + : cursorPosition; + + // Extract the link text + const linkText = content.substring(lastOpenBracket + 2, cursorPosition).trim(); + + return { + isInLink: true, + linkText, + startPos: lastOpenBracket, + endPos + }; +} From 1e9795e5d9b5c6d768c27c6abe8d0170bdc6494c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:06:15 +0000 Subject: [PATCH 17/55] feat: add quick capture floating widget for fast note creation - Create QuickCaptureWidget with floating action button - Support quick note capture with optional title - Auto-generate timestamped title if none provided - Add keyboard shortcuts: Ctrl+Enter to save, Escape to cancel - Integrate into desktop interface with reload on capture - Display visual feedback with kbd tags for shortcuts - Position as floating FAB in bottom-right corner - Refresh notes list automatically after capture --- src/components/QuickCaptureWidget.tsx | 145 ++++++++++++++++++++++++++ src/pages/Index.tsx | 4 + 2 files changed, 149 insertions(+) create mode 100644 src/components/QuickCaptureWidget.tsx diff --git a/src/components/QuickCaptureWidget.tsx b/src/components/QuickCaptureWidget.tsx new file mode 100644 index 0000000..d69140d --- /dev/null +++ b/src/components/QuickCaptureWidget.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { Zap, Send } from "lucide-react"; +import NotesService from "@/services/NotesService"; +import { showSuccess, showError } from "@/utils/toast"; + +interface QuickCaptureWidgetProps { + onNoteCaptured?: () => void; +} + +export function QuickCaptureWidget({ onNoteCaptured }: QuickCaptureWidgetProps) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleCapture = async () => { + if (!content.trim()) { + showError("Le contenu ne peut pas être vide"); + return; + } + + setIsSubmitting(true); + try { + const noteTitle = title.trim() || `Note rapide - ${new Date().toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit' + })}`; + + await NotesService.createNote(noteTitle, content); + showSuccess("Note capturée avec succès"); + + // Reset form + setTitle(""); + setContent(""); + setOpen(false); + + // Notify parent component + if (onNoteCaptured) { + onNoteCaptured(); + } + } catch (error) { + showError("Erreur lors de la capture de la note"); + } finally { + setIsSubmitting(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Cmd/Ctrl + Enter to submit + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + handleCapture(); + } + + // Escape to close + if (e.key === 'Escape') { + setOpen(false); + } + }; + + return ( + <> + {/* Floating Action Button */} + + + {/* Quick Capture Modal */} + + + + + + Capture rapide + + + Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer. + + + +
+ {/* Optional Title */} +
+ setTitle(e.target.value)} + className="text-lg font-medium" + autoFocus={false} + /> +

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

+
+ + {/* Content */} +