From 820b0cfeda824561d4665066eed6bb950d4e552e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Nov 2025 15:40:15 +0000 Subject: [PATCH] =?UTF-8?q?UX:=20Correction=20angles=20carte=20+=20Input?= =?UTF-8?q?=20inline=20todos=20+=20Syst=C3=A8me=20de=20fichiers=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Corrections UX: - Fix coins arrondis en bas des notes étendues (overflow: hidden) - Remplacement prompt() par input inline pour ajout de tâches - Bouton "Ajouter" avec validation au clic ou touche Entrée - Expérience utilisateur fluide sans popups intrusifs ## Système de fichiers complet: ### Backend: - Création table note_files (id, note_id, filename, original_name, file_size, mime_type, created_at) - POST /api/notes/:id/files - Upload fichier (multer, 10MB max) - GET /api/notes/:id/files - Liste fichiers d'une note - GET /api/notes/files/:fileId/download - Téléchargement sécurisé - DELETE /api/notes/files/:fileId - Suppression fichier - Stockage dans public/uploads/files/ - Auto-création dossier si nécessaire ### Frontend: - Bouton "Ajouter un fichier" dans footer des notes - Input hidden fileInput pour sélection - Affichage liste fichiers avec icône, nom, taille - Boutons télécharger/supprimer par fichier - Fonction formatFileSize() pour affichage lisible - Event delegation pour tous les boutons fichiers - Confirmation avant suppression ### Intégration: - GET /api/notes charge maintenant files[] et files_count - renderInlineFiles() génère HTML des fichiers - downloadFile() ouvre download dans nouvel onglet - deleteFile() avec confirmation dialog - Reload automatique après upload/delete - Conservation état étendu après opérations ### CSS moderne: - .note-file-item avec hover effect - .note-file-icon, .note-file-info, .note-file-actions - Buttons download/delete avec transitions - Design cohérent avec reste de l'app - Truncate nom fichier avec ellipsis Tous types de fichiers acceptés (PDF, DOC, ZIP, etc.) --- config/database.js | 15 +++ public/css/components.css | 160 ++++++++++++++++++++++++++++++-- public/index.html | 1 + public/js/complete-app.js | 189 ++++++++++++++++++++++++++++++++++++-- routes/notes.routes.js | 177 ++++++++++++++++++++++++++++++++++- 5 files changed, 526 insertions(+), 16 deletions(-) diff --git a/config/database.js b/config/database.js index 0253423..dbe6c19 100644 --- a/config/database.js +++ b/config/database.js @@ -74,10 +74,25 @@ function initDatabase() { ) `); + // Table note_files (fichiers attachés aux notes) + db.run(` + CREATE TABLE IF NOT EXISTS note_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE + ) + `); + // Créer les index pour la performance db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_note_files ON note_files(note_id)'); logger.info('✓ Tables de base de données créées avec succès'); diff --git a/public/css/components.css b/public/css/components.css index e72bb9c..6ecdf8c 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -28,6 +28,7 @@ cursor: default; box-shadow: var(--shadow-lg); border-color: var(--color-accent); + overflow: hidden; } .note-card.expanded:hover { @@ -312,26 +313,54 @@ font-style: italic; } -.btn-add-todo-inline { +.add-todo-inline-container { display: flex; - align-items: center; gap: var(--spacing-sm); + align-items: center; +} + +.add-todo-inline-input { + flex: 1; padding: var(--spacing-sm) var(--spacing-md); - border: 1px dashed var(--color-border); + border: 1px solid var(--color-border); border-radius: var(--radius-sm); - background: transparent; + background: var(--color-background); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + transition: all var(--transition-fast); +} + +.add-todo-inline-input:focus { + outline: none; + border-color: var(--color-accent); + background: var(--color-card); +} + +.add-todo-inline-input::placeholder { color: var(--color-text-secondary); +} + +.btn-add-todo-inline { + padding: var(--spacing-sm) var(--spacing-md); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + background: var(--color-accent); + color: white; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); cursor: pointer; transition: all var(--transition-fast); - width: 100%; + white-space: nowrap; } .btn-add-todo-inline:hover { - background: var(--color-hover); - border-color: var(--color-accent); - color: var(--color-text-primary); + background: var(--color-accent-hover); + box-shadow: var(--shadow-sm); +} + +.btn-add-todo-inline:disabled { + opacity: 0.5; + cursor: not-allowed; } .note-footer-inline { @@ -375,6 +404,121 @@ height: 100%; } +.btn-add-file-inline { + width: 36px; + height: 36px; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; +} + +.btn-add-file-inline:hover { + background: var(--color-hover); + color: var(--color-text-primary); + border-color: var(--color-accent); +} + +.btn-add-file-inline svg { + width: 100%; + height: 100%; +} + +.note-files-container-inline { + padding: 0 var(--spacing-lg) var(--spacing-md); +} + +.note-files-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.note-file-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: var(--color-background); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + transition: all var(--transition-fast); +} + +.note-file-item:hover { + background: var(--color-hover); + border-color: var(--color-accent); +} + +.note-file-icon { + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--color-text-secondary); +} + +.note-file-info { + flex: 1; + min-width: 0; +} + +.note-file-name { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-file-size { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.note-file-actions { + display: flex; + gap: var(--spacing-xs); +} + +.btn-download-file, +.btn-delete-file { + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; +} + +.btn-download-file:hover { + background: var(--color-accent); + color: white; +} + +.btn-delete-file:hover { + background: var(--color-danger); + color: white; +} + +.btn-download-file svg, +.btn-delete-file svg { + width: 100%; + height: 100%; +} + /* ============================================ TODO BOX ============================================ */ diff --git a/public/index.html b/public/index.html index b27f5f3..0933e3d 100644 --- a/public/index.html +++ b/public/index.html @@ -195,6 +195,7 @@ + diff --git a/public/js/complete-app.js b/public/js/complete-app.js index ad8f051..88620dd 100644 --- a/public/js/complete-app.js +++ b/public/js/complete-app.js @@ -327,21 +327,37 @@ function createNoteCard(note) { ` : ''} + ${note.files && note.files.length > 0 ? ` +
+
+ ${renderInlineFiles(note.files || [])} +
+
+ ` : ''}

Todos

${renderInlineTodos(note.todos || [])}
- +
+ + +
`; @@ -409,6 +425,49 @@ function renderInlineTodos(todos) { `).join(''); } +function renderInlineFiles(files) { + if (!files || files.length === 0) return ''; + + return files.map(file => { + const sizeStr = formatFileSize(file.file_size); + return ` +
+ + + + +
+
${escapeHtml(file.original_name)}
+
${sizeStr}
+
+
+ + +
+
+ `; + }).join(''); +} + +function formatFileSize(bytes) { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; @@ -484,12 +543,12 @@ async function deleteNoteInline(noteId) { } } -async function addNoteTodoInline(noteId) { - const text = prompt('Texte du todo:'); - if (!text || !text.trim()) return; +async function addNoteTodoInline(noteId, inputElement) { + const text = inputElement.value.trim(); + if (!text) return; try { - await api.post(`/api/notes/${noteId}/todos`, { text: text.trim() }); + await api.post(`/api/notes/${noteId}/todos`, { text }); // Recharger seulement cette note const note = await api.get(`/api/notes/${noteId}`); @@ -503,6 +562,10 @@ async function addNoteTodoInline(noteId) { if (todosList) { todosList.innerHTML = renderInlineTodos(note.todos || []); } + + // Clear input + inputElement.value = ''; + inputElement.focus(); } catch (error) { console.error('Erreur ajout todo:', error); alert('Erreur lors de l\'ajout du todo'); @@ -559,6 +622,14 @@ function triggerImageUpload(noteId) { } } +function triggerFileUpload(noteId) { + const fileInput = document.getElementById('fileInput'); + if (fileInput) { + fileInput.dataset.noteId = noteId; + fileInput.click(); + } +} + async function removeImageInline(noteId) { const confirmed = await confirmDialog.show({ icon: '🖼️', @@ -616,6 +687,70 @@ async function handleImageUploadInline(event) { event.target.dataset.noteId = ''; } +async function handleFileUploadInline(event) { + const file = event.target.files[0]; + const noteId = event.target.dataset.noteId; + + if (!file || !noteId) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const result = await api.uploadFile(`/api/notes/${noteId}/files`, formData); + + // Reload notes to get updated files list + await loadNotes(); + + // Keep the note expanded + state.expandedNoteId = parseInt(noteId); + renderNotes(); + } catch (error) { + console.error('Erreur upload fichier:', error); + alert('Erreur lors de l\'upload du fichier: ' + (error.message || 'Erreur inconnue')); + } + + // Reset input + event.target.value = ''; + event.target.dataset.noteId = ''; +} + +async function downloadFile(fileId) { + try { + // Ouvrir le lien de téléchargement dans un nouvel onglet + window.open(`/api/notes/files/${fileId}/download`, '_blank'); + } catch (error) { + console.error('Erreur téléchargement fichier:', error); + alert('Erreur lors du téléchargement du fichier'); + } +} + +async function deleteFile(fileId) { + const confirmed = await confirmDialog.show({ + icon: '📎', + title: 'Supprimer ce fichier', + message: 'Êtes-vous sûr de vouloir supprimer ce fichier ?', + okText: 'Supprimer' + }); + + if (!confirmed) return; + + try { + await api.delete(`/api/notes/files/${fileId}`); + + // Reload notes to get updated files list + await loadNotes(); + + // Keep the note expanded + if (state.expandedNoteId) { + renderNotes(); + } + } catch (error) { + console.error('Erreur suppression fichier:', error); + alert('Erreur lors de la suppression du fichier'); + } +} + async function createNewNote() { try { const newNote = await api.post('/api/notes', { @@ -1234,6 +1369,11 @@ async function init() { imageInput.addEventListener('change', handleImageUploadInline); } + const fileInput = document.getElementById('fileInput'); + if (fileInput) { + fileInput.addEventListener('change', handleFileUploadInline); + } + const removeImage = document.getElementById('removeImage'); if (removeImage) { removeImage.addEventListener('click', removeNoteImage); @@ -1381,7 +1521,18 @@ async function init() { const addTodoBtn = e.target.closest('.btn-add-todo-inline'); if (addTodoBtn) { const noteId = parseInt(addTodoBtn.dataset.noteId); - addNoteTodoInline(noteId); + const inputElement = document.querySelector(`.add-todo-inline-input[data-note-id="${noteId}"]`); + if (inputElement) { + addNoteTodoInline(noteId, inputElement); + } + return; + } + + // Add file button + const addFileBtn = e.target.closest('.btn-add-file-inline'); + if (addFileBtn) { + const noteId = parseInt(addFileBtn.dataset.noteId); + triggerFileUpload(noteId); return; } @@ -1391,6 +1542,22 @@ async function init() { deleteNoteTodoInline(todoId); return; } + + // Download file button + const downloadBtn = e.target.closest('.btn-download-file'); + if (downloadBtn) { + const fileId = parseInt(downloadBtn.dataset.fileId); + downloadFile(fileId); + return; + } + + // Delete file button + const deleteFileBtn = e.target.closest('.btn-delete-file'); + if (deleteFileBtn) { + const fileId = parseInt(deleteFileBtn.dataset.fileId); + deleteFile(fileId); + return; + } }); // Checkbox change for inline todos @@ -1400,6 +1567,14 @@ async function init() { toggleNoteTodoInline(todoId, e.target.checked); } }); + + // Enter key on add todo input + notesGrid.addEventListener('keypress', (e) => { + if (e.target.classList.contains('add-todo-inline-input') && e.key === 'Enter') { + const noteId = parseInt(e.target.dataset.noteId); + addNoteTodoInline(noteId, e.target); + } + }); } // Close modal on Escape diff --git a/routes/notes.routes.js b/routes/notes.routes.js index c20b5c9..1a29abf 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -58,7 +58,7 @@ router.get('/', async (req, res) => { ORDER BY n.updated_at DESC `, [req.user.id, archivedFilter]); - // Charger les todos pour chaque note + // Charger les todos et fichiers pour chaque note for (const note of notes) { const todos = await getAll(` SELECT id, text, completed, position @@ -67,6 +67,15 @@ router.get('/', async (req, res) => { ORDER BY position ASC, id ASC `, [note.id]); note.todos = todos || []; + + const files = await getAll(` + SELECT id, filename, original_name, file_size, mime_type, created_at + FROM note_files + WHERE note_id = ? + ORDER BY created_at DESC + `, [note.id]); + note.files = files || []; + note.files_count = files?.length || 0; } res.json(notes || []); @@ -478,5 +487,171 @@ const searchNotes = async (req, res) => { } }; +// ==================== FILE ATTACHMENTS ==================== + +// Configuration multer pour tous types de fichiers +const fileStorage = multer.diskStorage({ + destination: (req, file, cb) => { + const filesDir = path.join(__dirname, '../public/uploads/files'); + // Créer le dossier s'il n'existe pas + if (!fs.existsSync(filesDir)) { + fs.mkdirSync(filesDir, { recursive: true }); + } + cb(null, filesDir); + }, + filename: (req, file, cb) => { + const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.originalname}`; + cb(null, uniqueName); + } +}); + +const fileUpload = multer({ + storage: fileStorage, + limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024 } // 10MB +}); + +/** + * POST /api/notes/:id/files + * Ajouter un fichier à une note + */ +router.post('/:id/files', fileUpload.single('file'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'Aucun fichier fourni' }); + } + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + // Supprimer le fichier uploadé + fs.unlinkSync(req.file.path); + return res.status(404).json({ error: 'Note non trouvée' }); + } + + // Enregistrer le fichier dans la base de données + const result = await runQuery(` + INSERT INTO note_files (note_id, filename, original_name, file_size, mime_type) + VALUES (?, ?, ?, ?, ?) + `, [req.params.id, req.file.filename, req.file.originalname, req.file.size, req.file.mimetype]); + + // Mettre à jour la date de modification de la note + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]); + + logger.info(`Fichier ajouté à la note ${req.params.id}: ${req.file.originalname}`); + + res.json({ + message: 'Fichier ajouté avec succès', + file: { + id: result.id, + filename: req.file.filename, + original_name: req.file.originalname, + file_size: req.file.size, + mime_type: req.file.mimetype + } + }); + } catch (error) { + logger.error('Erreur lors de l\'ajout du fichier:', error); + if (req.file && fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * GET /api/notes/:id/files + * Liste les fichiers d'une note + */ +router.get('/:id/files', async (req, res) => { + try { + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + const files = await getAll(` + SELECT id, filename, original_name, file_size, mime_type, created_at + FROM note_files + WHERE note_id = ? + ORDER BY created_at DESC + `, [req.params.id]); + + res.json(files); + } catch (error) { + logger.error('Erreur lors de la récupération des fichiers:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * GET /api/notes/files/:fileId/download + * Télécharger un fichier + */ +router.get('/files/:fileId/download', async (req, res) => { + try { + // Vérifier que le fichier appartient à une note de l'utilisateur + const file = await getOne(` + SELECT nf.id, nf.filename, nf.original_name, nf.mime_type + FROM note_files nf + JOIN notes n ON nf.note_id = n.id + WHERE nf.id = ? AND n.user_id = ? + `, [req.params.fileId, req.user.id]); + + if (!file) { + return res.status(404).json({ error: 'Fichier non trouvé' }); + } + + const filePath = path.join(__dirname, '../public/uploads/files', file.filename); + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'Fichier physique non trouvé' }); + } + + res.download(filePath, file.original_name); + } catch (error) { + logger.error('Erreur lors du téléchargement du fichier:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * DELETE /api/notes/files/:fileId + * Supprimer un fichier + */ +router.delete('/files/:fileId', async (req, res) => { + try { + // Vérifier que le fichier appartient à une note de l'utilisateur + const file = await getOne(` + SELECT nf.id, nf.note_id, nf.filename + FROM note_files nf + JOIN notes n ON nf.note_id = n.id + WHERE nf.id = ? AND n.user_id = ? + `, [req.params.fileId, req.user.id]); + + if (!file) { + return res.status(404).json({ error: 'Fichier non trouvé' }); + } + + // Supprimer le fichier physique + const filePath = path.join(__dirname, '../public/uploads/files', file.filename); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + + // Supprimer de la base de données + await runQuery('DELETE FROM note_files WHERE id = ?', [req.params.fileId]); + + // Mettre à jour la date de modification de la note + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [file.note_id]); + + logger.info(`Fichier supprimé: ${file.filename}`); + + res.json({ message: 'Fichier supprimé avec succès' }); + } catch (error) { + logger.error('Erreur lors de la suppression du fichier:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + module.exports = router; module.exports.searchNotes = searchNotes;