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;