UX: Correction angles carte + Input inline todos + Système de fichiers complet

## 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.)
This commit is contained in:
Claude committed 2025-11-11 15:40:15 +00:00
1 parent 17a088ec05
commit 820b0cfeda
5 files changed
+526 -16

No files matched your search

+15
View File
@@ -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');
+152 -8
View File
@@ -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
============================================ */
+1
View File
@@ -195,6 +195,7 @@
<!-- Hidden file input -->
<input type="file" id="imageInput" accept="image/*" style="display: none;">
<input type="file" id="fileInput" style="display: none;">
<!-- JavaScript -->
<script src="/js/complete-app.js"></script>
+182 -7
View File
@@ -327,21 +327,37 @@ function createNoteCard(note) {
<button class="btn-remove-image-inline" data-note-id="${note.id}">✕</button>
</div>
` : ''}
${note.files && note.files.length > 0 ? `
<div class="note-files-container-inline">
<div class="note-files-list" id="files-${note.id}">
${renderInlineFiles(note.files || [])}
</div>
</div>
` : ''}
<div class="note-todos-inline">
<h4>Todos</h4>
<div class="todos-list-inline" id="todos-${note.id}">
${renderInlineTodos(note.todos || [])}
</div>
<button class="btn-add-todo-inline" data-note-id="${note.id}">+ Ajouter une tâche</button>
<div class="add-todo-inline-container">
<input type="text" class="add-todo-inline-input" placeholder="Ajouter une tâche..." data-note-id="${note.id}">
<button class="btn-add-todo-inline" data-note-id="${note.id}">Ajouter</button>
</div>
</div>
<div class="note-footer-inline">
<button class="btn-add-image-inline" data-note-id="${note.id}">
<button class="btn-add-image-inline" data-note-id="${note.id}" title="Ajouter une image">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</button>
<button class="btn-add-file-inline" data-note-id="${note.id}" title="Ajouter un fichier">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
</button>
<span class="note-meta-inline">${utils.formatDate(note.updated_at)}</span>
</div>
`;
@@ -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 `
<div class="note-file-item" data-file-id="${file.id}">
<svg class="note-file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
<div class="note-file-info">
<div class="note-file-name">${escapeHtml(file.original_name)}</div>
<div class="note-file-size">${sizeStr}</div>
</div>
<div class="note-file-actions">
<button class="btn-download-file" data-file-id="${file.id}" title="Télécharger">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
<button class="btn-delete-file" data-file-id="${file.id}" title="Supprimer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
}).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
+176 -1
View File
@@ -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;